Technology · ssh / indianDevelopers
LLD 06 — Design a Coffee Machine: Recipes, Inventory and Java Code
A coffee machine becomes an interesting LLD problem when adding a new drink should not require another subclass. Model recipes as data, protect ingredient inventory and keep machine orchestration separate from preparation hardware.
Say our coffee machine already knows how to make an espresso.
Tomorrow somebody asks us to add cappuccino.
Then latte.
Then americano.
A very natural first attempt is:
Coffee
├── Espresso
├── Cappuccino
├── Latte
└── Americano
It looks object-oriented. It also looks suspicious.
What actually makes those drinks different in our machine?
Mostly this:
which ingredients are required
+
how much of each ingredient is required
+
in what sequence the machine uses them
If every new menu item forces us to create another class even though the behaviour is largely the same, we may be modelling the menu rather than the problem.
That makes a Coffee Machine a useful LLD exercise.
Not because brewing coffee is complicated Java.
Because it forces us to decide what should be code and what should simply be data.
First, Clarify Which Coffee Machine We Mean
This matters because “design a coffee machine” can describe two different interview problems.
One version is basically a vending machine:
select drink
→ insert money
→ calculate change
→ dispense
We already handled that kind of transaction lifecycle in the vending-machine problem.
For this article, we are interested in the machine itself.
Our version should:
support several beverages;
keep recipes for those beverages;
track water, milk and coffee-bean inventory;
reject an order when ingredients are insufficient;
deduct ingredients exactly once when preparation begins;
execute the recipe;
allow an operator to refill ingredients;
make adding another recipe inexpensive.
We are deliberately leaving out:
payments;
coins and change;
cup inventory;
cleaning cycles;
temperature sensors;
grinder calibration;
hardware faults;
drink-size customization;
user accounts;
persistence.
If an interviewer wants those, we can extend the model later.
Do We Really Need One Class Per Drink?
Suppose an espresso needs:
18 g coffee beans
30 ml water
and an americano needs:
18 g coffee beans
120 ml water
Do those two drinks have fundamentally different identities and lifecycles inside our application?
Not really.
They are two configurations of the same preparation system.
That suggests:
BeverageType
+
BeverageRecipe
+
RecipeStep
instead of:
EspressoCoffee
AmericanoCoffee
LatteCoffee
CappuccinoCoffee
The difference sounds cosmetic, but it matters.
With recipes as data, adding a drink can often mean adding another recipe to the catalogue rather than modifying the machine's core logic.
Our high-level model becomes:
CoffeeMachine
│
├── RecipeCatalog
│ └── BeverageRecipe
│ └── RecipeStep
│
├── Inventory
│
└── PreparationUnit
The machine coordinates the workflow. The recipe describes what should happen. Inventory owns stock. The preparation unit represents the mechanism that actually performs the steps.
That is a much healthier split than asking Latte to know how much milk remains inside the machine.
Ingredients Need Units Too
Writing:
inventory.put(WATER, 500);
inventory.put(COFFEE_BEANS, 500);
is ambiguous.
Five hundred what?
For our simplified model, every ingredient has one canonical unit.
public enum Unit {
GRAM,
MILLILITER
}
public enum Ingredient {
COFFEE_BEANS(Unit.GRAM),
WATER(Unit.MILLILITER),
MILK(Unit.MILLILITER);
private final Unit unit;
Ingredient(Unit unit) {
this.unit = unit;
}
public Unit unit() {
return unit;
}
}
That is enough for the exercise.
If a later requirement needs several measurement systems or conversion, then quantity deserves a richer value object.
We should not solve that problem before it exists.
A Recipe Is an Ordered List of Steps
Ingredient totals tell us whether a drink can be made.
But preparation also has an order.
So instead of storing only:
coffee beans = 18
water = 30
milk = 120
we can represent the actual sequence the controller wants to execute.
public enum PreparationAction {
GRIND_BEANS,
BREW_WITH_WATER,
ADD_HOT_WATER,
STEAM_MILK
}
public record RecipeStep(
PreparationAction action,
Ingredient ingredient,
int quantity
) {
public RecipeStep {
Objects.requireNonNull(action);
Objects.requireNonNull(ingredient);
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
}
}
A recipe can then be immutable:
public final class BeverageRecipe {
private final BeverageType type;
private final List<RecipeStep> steps;
public BeverageRecipe(
BeverageType type,
List<RecipeStep> steps) {
this.type =
Objects.requireNonNull(type);
if (steps.isEmpty()) {
throw new IllegalArgumentException(
"Recipe must contain steps"
);
}
this.steps = List.copyOf(steps);
}
public BeverageType type() {
return type;
}
public List<RecipeStep> steps() {
return steps;
}
public Map<Ingredient, Integer>
requiredIngredients() {
EnumMap<Ingredient, Integer> totals =
new EnumMap<>(Ingredient.class);
for (RecipeStep step : steps) {
totals.merge(
step.ingredient(),
step.quantity(),
Integer::sum
);
}
return Map.copyOf(totals);
}
}
Notice that one ingredient can occur in more than one step.
An americano, for example, might use water during brewing and again when hot water is added afterwards.
requiredIngredients() aggregates those steps before we touch inventory.
Keep Recipes in a Catalogue
The machine should not contain this:
if (type == ESPRESSO) {
...
} else if (type == LATTE) {
...
} else if (type == CAPPUCCINO) {
...
}
That if chain grows every time the menu changes.
A recipe catalogue gives the machine a much simpler dependency.
public enum BeverageType {
ESPRESSO,
AMERICANO,
CAPPUCCINO,
LATTE
}
public final class RecipeCatalog {
private final Map<BeverageType, BeverageRecipe>
recipes;
public RecipeCatalog(
List<BeverageRecipe> recipes) {
EnumMap<BeverageType, BeverageRecipe> map =
new EnumMap<>(BeverageType.class);
for (BeverageRecipe recipe : recipes) {
if (map.put(
recipe.type(),
recipe) != null) {
throw new IllegalArgumentException(
"Duplicate recipe: "
+ recipe.type()
);
}
}
this.recipes = Map.copyOf(map);
}
public BeverageRecipe recipeFor(
BeverageType type) {
BeverageRecipe recipe =
recipes.get(type);
if (recipe == null) {
throw new IllegalArgumentException(
"Unsupported beverage: " + type
);
}
return recipe;
}
public Set<BeverageType> beverageTypes() {
return recipes.keySet();
}
}
This is closer to a registry/catalogue than a Factory.
And that distinction is worth making.
We are not constructing fundamentally different object graphs for every drink. We are looking up recipe data.
Calling everything a Factory just because objects are involved usually makes the design harder to explain, not better.
Inventory Owns Stock
Inventory should answer questions such as:
How much water remains?
Can this recipe be made?
Can these ingredients be consumed atomically?
Refill milk.
It should not know what a cappuccino is.
public final class Inventory {
private final EnumMap<Ingredient, Integer> stock =
new EnumMap<>(Ingredient.class);
public Inventory(
Map<Ingredient, Integer> initialStock) {
for (Ingredient ingredient
: Ingredient.values()) {
stock.put(
ingredient,
initialStock.getOrDefault(
ingredient,
0
)
);
}
}
public synchronized boolean tryConsume(
Map<Ingredient, Integer> required) {
for (var entry : required.entrySet()) {
int available =
stock.getOrDefault(
entry.getKey(),
0
);
if (available < entry.getValue()) {
return false;
}
}
for (var entry : required.entrySet()) {
stock.compute(
entry.getKey(),
(ingredient, available) ->
available - entry.getValue()
);
}
return true;
}
public synchronized boolean canMake(
Map<Ingredient, Integer> required) {
for (var entry : required.entrySet()) {
if (stock.getOrDefault(
entry.getKey(),
0
) < entry.getValue()) {
return false;
}
}
return true;
}
public synchronized void refill(
Ingredient ingredient,
int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Refill quantity must be positive"
);
}
stock.merge(
ingredient,
quantity,
Integer::sum
);
}
public synchronized Map<Ingredient, Integer>
snapshot() {
return Map.copyOf(stock);
}
}
There is an important reason tryConsume() does both the check and deduction.
Do Not Check Stock and Deduct Stock Separately

Imagine the machine receives two requests almost together.
Both need:
18 g coffee beansand only:
20 gremain.
This is dangerous:
if (inventory.canMake(recipe)) {
inventory.consume(recipe);
}
Two threads could both observe 20 grams before either deducts anything.
Both conclude:
Enough beans.
Then both start preparing.
We have promised 36 grams of beans while physically owning 20.
The meaningful inventory operation is therefore not:
check()
followed later by:
deduct()
It is:
tryConsume(requiredIngredients)
where validation and mutation happen inside the same critical section.
The invariant is:
A recipe either reserves all required ingredients, or consumes none of them.
That is much more useful than sprinkling synchronized randomly across methods.
Separate Preparation From Machine Coordination
Our Java program is not controlling a real pump, heater or grinder.
So we should not pretend:
Thread.sleep(3000);
is a coffee-machine hardware implementation.
Instead, define the boundary.
public interface PreparationUnit {
void prepare(BeverageRecipe recipe);
}
For a runnable example, we can provide a simulation:
public final class SimulatedPreparationUnit
implements PreparationUnit {
@Override
public void prepare(
BeverageRecipe recipe) {
for (RecipeStep step
: recipe.steps()) {
System.out.printf(
"%s: %d %s of %s%n",
step.action(),
step.quantity(),
step.ingredient().unit(),
step.ingredient()
);
}
}
}
In a real machine, an implementation could translate recipe steps into commands for actual hardware.
The domain workflow would not need to know those electrical or mechanical details.
CoffeeMachine Coordinates One Preparation
Now the main class becomes fairly small.
public enum MachineState {
READY,
PREPARING
}
public record Beverage(
BeverageType type,
Instant preparedAt
) {}
public final class CoffeeMachine {
private final RecipeCatalog catalog;
private final Inventory inventory;
private final PreparationUnit preparationUnit;
private MachineState state =
MachineState.READY;
public CoffeeMachine(
RecipeCatalog catalog,
Inventory inventory,
PreparationUnit preparationUnit) {
this.catalog =
Objects.requireNonNull(catalog);
this.inventory =
Objects.requireNonNull(inventory);
this.preparationUnit =
Objects.requireNonNull(
preparationUnit
);
}
public synchronized Beverage make(
BeverageType type) {
if (state != MachineState.READY) {
throw new IllegalStateException(
"Machine is busy"
);
}
BeverageRecipe recipe =
catalog.recipeFor(type);
Map<Ingredient, Integer> required =
recipe.requiredIngredients();
if (!inventory.tryConsume(required)) {
throw new InsufficientIngredientsException(
"Not enough ingredients for "
+ type
);
}
state = MachineState.PREPARING;
try {
preparationUnit.prepare(recipe);
return new Beverage(
type,
Instant.now()
);
} finally {
state = MachineState.READY;
}
}
public void refill(
Ingredient ingredient,
int quantity) {
inventory.refill(
ingredient,
quantity
);
}
public List<BeverageType>
availableBeverages() {
return catalog.beverageTypes()
.stream()
.filter(type ->
inventory.canMake(
catalog.recipeFor(type)
.requiredIngredients()
)
)
.toList();
}
public MachineState state() {
return state;
}
public Map<Ingredient, Integer>
inventorySnapshot() {
return inventory.snapshot();
}
}
The complete runtime path is now:
select beverage
↓
look up recipe
↓
aggregate ingredients
↓
atomically reserve inventory
↓
execute recipe steps
↓
return prepared beverage

The important part is what CoffeeMachine does not contain.
It does not know the recipe for every beverage.
It does not own ingredient quantities directly.
It does not know how a physical grinder works.
It coordinates objects that each own a smaller responsibility.
Complete Runnable Java Version
Here is the core design in one file.
import java.time.Instant;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
enum Unit {
GRAM,
MILLILITER
}
enum Ingredient {
COFFEE_BEANS(Unit.GRAM),
WATER(Unit.MILLILITER),
MILK(Unit.MILLILITER);
private final Unit unit;
Ingredient(Unit unit) {
this.unit = unit;
}
public Unit unit() {
return unit;
}
}
enum BeverageType {
ESPRESSO,
AMERICANO,
CAPPUCCINO,
LATTE
}
enum PreparationAction {
GRIND_BEANS,
BREW_WITH_WATER,
ADD_HOT_WATER,
STEAM_MILK
}
enum MachineState {
READY,
PREPARING
}
record RecipeStep(
PreparationAction action,
Ingredient ingredient,
int quantity
) {
RecipeStep {
Objects.requireNonNull(action);
Objects.requireNonNull(ingredient);
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
}
}
record Beverage(
BeverageType type,
Instant preparedAt
) {}
final class InsufficientIngredientsException
extends RuntimeException {
InsufficientIngredientsException(
String message) {
super(message);
}
}
final class BeverageRecipe {
private final BeverageType type;
private final List<RecipeStep> steps;
BeverageRecipe(
BeverageType type,
List<RecipeStep> steps) {
this.type =
Objects.requireNonNull(type);
if (steps.isEmpty()) {
throw new IllegalArgumentException(
"Recipe must contain steps"
);
}
this.steps =
List.copyOf(steps);
}
BeverageType type() {
return type;
}
List<RecipeStep> steps() {
return steps;
}
Map<Ingredient, Integer>
requiredIngredients() {
EnumMap<Ingredient, Integer> totals =
new EnumMap<>(Ingredient.class);
for (RecipeStep step : steps) {
totals.merge(
step.ingredient(),
step.quantity(),
Integer::sum
);
}
return Map.copyOf(totals);
}
}
final class RecipeCatalog {
private final Map<
BeverageType,
BeverageRecipe
> recipes;
RecipeCatalog(
List<BeverageRecipe> recipes) {
EnumMap<
BeverageType,
BeverageRecipe
> map =
new EnumMap<>(
BeverageType.class
);
for (BeverageRecipe recipe
: recipes) {
if (map.put(
recipe.type(),
recipe) != null) {
throw new IllegalArgumentException(
"Duplicate recipe: "
+ recipe.type()
);
}
}
this.recipes =
Map.copyOf(map);
}
BeverageRecipe recipeFor(
BeverageType type) {
BeverageRecipe recipe =
recipes.get(type);
if (recipe == null) {
throw new IllegalArgumentException(
"Unsupported beverage: "
+ type
);
}
return recipe;
}
Set<BeverageType> beverageTypes() {
return recipes.keySet();
}
}
final class Inventory {
private final EnumMap<
Ingredient,
Integer
> stock =
new EnumMap<>(Ingredient.class);
Inventory(
Map<Ingredient, Integer>
initialStock) {
for (Ingredient ingredient
: Ingredient.values()) {
int quantity =
initialStock.getOrDefault(
ingredient,
0
);
if (quantity < 0) {
throw new IllegalArgumentException(
"Stock cannot be negative"
);
}
stock.put(
ingredient,
quantity
);
}
}
synchronized boolean tryConsume(
Map<Ingredient, Integer>
required) {
for (var entry
: required.entrySet()) {
int available =
stock.getOrDefault(
entry.getKey(),
0
);
if (available
< entry.getValue()) {
return false;
}
}
for (var entry
: required.entrySet()) {
stock.compute(
entry.getKey(),
(ingredient, available) ->
available
- entry.getValue()
);
}
return true;
}
synchronized boolean canMake(
Map<Ingredient, Integer>
required) {
for (var entry
: required.entrySet()) {
if (stock.getOrDefault(
entry.getKey(),
0
) < entry.getValue()) {
return false;
}
}
return true;
}
synchronized void refill(
Ingredient ingredient,
int quantity) {
Objects.requireNonNull(ingredient);
if (quantity <= 0) {
throw new IllegalArgumentException(
"Refill must be positive"
);
}
stock.merge(
ingredient,
quantity,
Integer::sum
);
}
synchronized Map<
Ingredient,
Integer
> snapshot() {
return Map.copyOf(stock);
}
}
interface PreparationUnit {
void prepare(
BeverageRecipe recipe
);
}
final class SimulatedPreparationUnit
implements PreparationUnit {
@Override
public void prepare(
BeverageRecipe recipe) {
System.out.println(
"Preparing "
+ recipe.type()
);
for (RecipeStep step
: recipe.steps()) {
System.out.printf(
" %s -> %d %s of %s%n",
step.action(),
step.quantity(),
step.ingredient().unit(),
step.ingredient()
);
}
}
}
final class CoffeeMachine {
private final RecipeCatalog catalog;
private final Inventory inventory;
private final PreparationUnit
preparationUnit;
private MachineState state =
MachineState.READY;
CoffeeMachine(
RecipeCatalog catalog,
Inventory inventory,
PreparationUnit preparationUnit) {
this.catalog =
Objects.requireNonNull(catalog);
this.inventory =
Objects.requireNonNull(inventory);
this.preparationUnit =
Objects.requireNonNull(
preparationUnit
);
}
synchronized Beverage make(
BeverageType type) {
if (state
!= MachineState.READY) {
throw new IllegalStateException(
"Machine is busy"
);
}
BeverageRecipe recipe =
catalog.recipeFor(type);
Map<Ingredient, Integer>
required =
recipe.requiredIngredients();
if (!inventory.tryConsume(
required)) {
throw new
InsufficientIngredientsException(
"Not enough ingredients for "
+ type
);
}
state =
MachineState.PREPARING;
try {
preparationUnit.prepare(
recipe
);
return new Beverage(
type,
Instant.now()
);
} finally {
state =
MachineState.READY;
}
}
void refill(
Ingredient ingredient,
int quantity) {
inventory.refill(
ingredient,
quantity
);
}
List<BeverageType>
availableBeverages() {
return catalog
.beverageTypes()
.stream()
.filter(type ->
inventory.canMake(
catalog
.recipeFor(type)
.requiredIngredients()
)
)
.toList();
}
Map<Ingredient, Integer>
inventorySnapshot() {
return inventory.snapshot();
}
}
public class Main {
public static void main(
String[] args) {
BeverageRecipe espresso =
new BeverageRecipe(
BeverageType.ESPRESSO,
List.of(
new RecipeStep(
PreparationAction
.GRIND_BEANS,
Ingredient
.COFFEE_BEANS,
18
),
new RecipeStep(
PreparationAction
.BREW_WITH_WATER,
Ingredient.WATER,
30
)
)
);
BeverageRecipe americano =
new BeverageRecipe(
BeverageType.AMERICANO,
List.of(
new RecipeStep(
PreparationAction
.GRIND_BEANS,
Ingredient
.COFFEE_BEANS,
18
),
new RecipeStep(
PreparationAction
.BREW_WITH_WATER,
Ingredient.WATER,
30
),
new RecipeStep(
PreparationAction
.ADD_HOT_WATER,
Ingredient.WATER,
90
)
)
);
BeverageRecipe cappuccino =
new BeverageRecipe(
BeverageType.CAPPUCCINO,
List.of(
new RecipeStep(
PreparationAction
.GRIND_BEANS,
Ingredient
.COFFEE_BEANS,
18
),
new RecipeStep(
PreparationAction
.BREW_WITH_WATER,
Ingredient.WATER,
30
),
new RecipeStep(
PreparationAction
.STEAM_MILK,
Ingredient.MILK,
120
)
)
);
BeverageRecipe latte =
new BeverageRecipe(
BeverageType.LATTE,
List.of(
new RecipeStep(
PreparationAction
.GRIND_BEANS,
Ingredient
.COFFEE_BEANS,
18
),
new RecipeStep(
PreparationAction
.BREW_WITH_WATER,
Ingredient.WATER,
30
),
new RecipeStep(
PreparationAction
.STEAM_MILK,
Ingredient.MILK,
180
)
)
);
RecipeCatalog catalog =
new RecipeCatalog(
List.of(
espresso,
americano,
cappuccino,
latte
)
);
Inventory inventory =
new Inventory(
Map.of(
Ingredient.COFFEE_BEANS,
200,
Ingredient.WATER,
1000,
Ingredient.MILK,
500
)
);
CoffeeMachine machine =
new CoffeeMachine(
catalog,
inventory,
new SimulatedPreparationUnit()
);
System.out.println(
"Available: "
+ machine
.availableBeverages()
);
Beverage drink =
machine.make(
BeverageType.CAPPUCCINO
);
System.out.println(
"Prepared: "
+ drink.type()
);
System.out.println(
"Inventory: "
+ machine
.inventorySnapshot()
);
}
}
The recipe values above are deliberately illustrative values for the software-design exercise, not a specification for commercial coffee preparation.
What matters here is the modelling. Some more details on this LLD-06 will post in another article please see the community .

Conversation
Comments
Sign in to join the conversation.