Where code lives: layers, repositories and packages
Part of the free Low-Level Design course on LogicWiz, in the chapter: Code that survives review. The class-diagram exercises run in the app.
Where code lives: layers, repositories and packages
"I opened the file to find where an order is saved. It took me four minutes, and I wrote the file."
Priya, reading Arjun's first order code
One class that does every job
Arjun's first version of "place an order" was one class. It worked. Priya asked him to point at the line that enforces the minimum order, and he had to search.
public class OrderManager {
static Map<String, Object[]> orders = new HashMap<>();
public static void place(String user, String sku, int qty, double price) {
String id = "O" + System.currentTimeMillis();
double total = qty * price;
if (total < 99) {
System.out.println("minimum order is 99");
return;
}
orders.put(id, new Object[] {user, sku, qty, total, "PLACED"});
System.out.println("placed " + id);
}
}
The class does four jobs. It holds data (an order is an array of objects). It applies rules (the minimum). It stores orders (a static map). It talks to the outside (it prints). Money is a double, and the id comes from the time.
Arjun said: "Nobody reads my folders in a round. One file is faster." Typing is not the cost. Reading and changing are. A reviewer has a few minutes, and the layout tells them where to look. A good layout answers "where is the rule?" in one guess.
Count what the pile costs you on a normal day:
- A rule changes. The minimum order moves from 99 to 149 rupees. You must read the code that stores and prints to find the one line.
- Storage changes. Orders must go to a database. The static map is used inside the same method as the rule, so you edit the rule's method to change storage.
- A test is needed. You cannot test the minimum without printing to the screen and filling a global map that every other test also fills.
- A new person joins. They cannot tell what is a rule and what is a detail.
Four kinds of class, each in its place
Split the class by job. Each job gets its own group of classes, called a layer:
| Layer | Its job | SwiftKart classes |
|---|---|---|
| Domain | Hold data and enforce the rules | Order, OrderLine, Money |
| Repository | Store and find domain objects | OrderRepository and a map-based class |
| Service | Run one use case by calling the others | OrderService |
| Entry point | Take input, show output | Main |
The domain classes guard their own rules, as the encapsulation lesson taught. An Order refuses to exist with no lines, and it refuses to cancel once it is packed.
public class Order {
private final String id;
private final String customerId;
private final List<OrderLine> lines;
private final Instant placedAt;
private OrderStatus status = OrderStatus.PLACED;
public Order(String id, String customerId, List<OrderLine> lines, Instant placedAt) {
if (lines.isEmpty()) {
throw new IllegalArgumentException("an order needs at least one line");
}
this.id = id;
this.customerId = customerId;
this.lines = List.copyOf(lines);
this.placedAt = placedAt;
}
public void cancel() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException("only a placed order can be cancelled");
}
status = OrderStatus.CANCELLED;
}
// total(), id(), status() and the other accessors are left out
}
A repository stores and finds one kind of object. It is an interface, and the first implementation keeps a Map in memory.
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> byId = new HashMap<>();
public void save(Order order) { byId.put(order.id(), order); }
public Optional<Order> findById(String id) {
return Optional.ofNullable(byId.get(id));
}
}
Why an interface for a map? In a round you have no database. The interface lets the service work today and lets a database-backed class replace the map later by changing one line in Main. There is a catch. A map returns the same object you saved. A database gives you a fresh copy each time. So always call save after you change an order, even though the map would not need it. Code written that way survives the swap.
Do not write a generic Repository<T, ID> with paging in a round. One small interface per kind of object is enough.
Which class may know about which
Draw an arrow from a class to every class it knows about.
flowchart TD
Main --> OrderService
OrderService --> OrderRepository
OrderService --> Order
InMemoryOrderRepository -. implements .-> OrderRepository
InMemoryOrderRepository --> Order
Every arrow points toward the domain, and Order has no arrow leaving it. That is the rule: the domain depends on nothing. Order imports no service, no repository, no console code. Business rules change rarely, while screens and storage change often. If Order knew about storage, a storage change would reach the rule. A house is a fair picture. The wiring in the walls does not know which lamp you will buy, and the lamp does not know how the wiring was laid. It stops being fair at one point: the builder fixes the socket, while here you pick the implementation when the program starts.
Here is what a wrong arrow looks like. Suppose Arjun adds a convenience method to the domain class:
public class Order {
public void saveTo(OrderRepository repository) { // wrong direction
repository.save(this);
}
}
Now Order knows about the repository. A new storage method forces a change in Order, and you cannot compile or test the rules without the storage code beside them. Keep the call in the service, which already knows both.
The service shows the dependency inversion principle at work. It owns the need ("save and find orders") and states it as an interface. The map-based class depends on that interface, so its arrow also points toward the abstraction.
public class OrderService {
private final OrderRepository orders;
private final ProductRepository products;
private final Supplier<String> ids;
private final Clock clock;
public OrderService(OrderRepository orders, ProductRepository products,
Supplier<String> ids, Clock clock) { /* assign fields */ }
public OrderView cancel(String orderId) {
Order order = orders.findById(orderId).orElseThrow(
() -> new IllegalArgumentException("unknown order " + orderId));
order.cancel();
orders.save(order);
return OrderView.of(order);
}
}
Main is the composition root from the singleton lesson: the one place that chooses the concrete classes and passes them in.
Follow one order through the layers when Main runs the demo:
Mainbuilds aPlaceOrderRequestfor customer C1 with two lines and callsplaceOrder.- The service asks the product repository for each sku, so the price always comes from the catalogue and never from the caller.
- The service builds an
Order. The id comes from the suppliedids, and the time comes from the clock. - The
Orderchecks its own rule. An empty list would be refused here. - The service saves the order and returns an
OrderView.Mainprints it.
For two lines (2 packs of milk at 6800 paise each, 3 boxes of eggs at 9999 paise each), then a cancel, then a second cancel, the demo prints:
OrderView[id=O1001, status=PLACED, total=Rs 435.97, itemCount=2]
OrderView[id=O1001, status=CANCELLED, total=Rs 435.97, itemCount=2]
rejected: only a placed order can be cancelled
The second cancel is refused by the domain rule, not by a check in Main. That is the benefit of putting rules in the domain: every entry point gets them.
Data transfer objects and domain objects
OrderService returns an OrderView and takes a PlaceOrderRequest. These are data transfer objects (DTOs): plain carriers of data across a boundary, with no rules inside. The domain Order has rules and may change state. A DTO only holds values.
public record LineRequest(String sku, int quantity) {}
public record PlaceOrderRequest(String customerId, List<LineRequest> lines) {}
public record OrderView(String id, String status, String total, int itemCount) {
public static OrderView of(Order order) {
return new OrderView(order.id(), order.status().name(),
order.total().toString(), order.lines().size());
}
}
Why bother? The outside world sends strings and numbers, and wants text back, such as Rs 435.97. The domain wants Money and an enum. If Order itself crossed the boundary, any caller could change its state, and a change to the screen would touch the rules. The cost is a few extra classes and a mapping method. So use two or three DTOs at the main entry points, not one per class. In a small problem where main passes plain arguments, skip them and say why.
Packages: by layer or by feature
A package is a named folder of classes. You can group by layer or by feature:
by layer by feature
domain/ Order, Money order/ Order, OrderService, OrderRepository
service/ OrderService cart/ Cart, CartService, CartRepository
repository/ OrderRepository shared/ Money
Grouping by layer is easy to explain, and all classes of one kind sit together. A change to "orders" then touches three folders. Grouping by feature keeps what changes together in one place and is easier to split later. But it needs a shared place for common types, and you must decide which features may call which. For a small round with three to six classes, either choice is fine. Interviewers do not grade the choice. They grade whether you can say why. If the round allows only one file, keep the layers as comment banners and keep the arrows pointing the same way.
Ids, money and time
Three small choices cause most late bugs in a round.
Money. Never use double. It cannot hold most decimal fractions exactly. In Java, 0.1 + 0.2 prints 0.30000000000000004, and 99.99 * 3 prints 299.96999999999997. Store whole paise in a long, usually inside a Money record. Three eggs at 9999 paise are 29997 paise, exactly.
Ids. System.currentTimeMillis() gives two orders placed in the same millisecond the same id. Pass in a Supplier<String>, a standard interface with one method get() that takes nothing and returns a value. Main supplies a counter, and a test supplies () -> "O1". A random UUID is fine in real code, but a test cannot predict it.
Time. Pass in a Clock, as the testing lesson showed, and store an Instant, a point on the timeline. Turn it into text only at the entry point.
OrderService service = new OrderService(orders, products, () -> "O1", fixedClock);
OrderView view = service.placeOrder(new PlaceOrderRequest("C1",
List.of(new LineRequest("MILK1", 2))));
// view.id() is "O1" and view.total() is "Rs 136.00", every run
Now draw it. The starter is Arjun's OrderManager. Split it into a service, a repository interface with a map-based class, and domain classes.
📌 Summary: Split code into domain, repository, service and entry point. Arrows point toward the domain, which depends on nothing. Keep money in paise, and pass in ids and the clock.
What Arjun learns next
The order flow now has a clear layout. But checkout, payment and delivery run at the same time in the real app, and two customers can buy the last pack of eggs together. Next chapter: thread safety, locks and double booking.
Check your understanding
- Arjun adds `saveTo(OrderRepository repository)` to the Order class. What is wrong with it?
- OrderService.cancel calls `orders.save(order)` after `order.cancel()`, although the in-memory map returns the same object. Why keep the save?
- A price of 99.99 times 3 gives 299.96999999999997 in a double. What is the best fix for money in a round?
- A candidate says: "I made a DTO and a mapper for every class, so my design is clean." What is the best reply?