Part 01 · Low Level Design
Design Principles: DRY, KISS, YAGNI
Copy-paste, over-engineering and a.b().c().d() chains: the everyday pain each principle cures, and the cases where breaking the rule is the right call.
⏱ 1-2 hours · Station 5 of 80
Three screens, three delivery fees
A food delivery app shows the delivery fee in three places: the checkout screen, the receipt, and the refund job that pays customers back when an order fails. Each was written by a different developer, and each wrote the fee rule itself: "40 taka for the first 3 km, then 10 taka per km, free in Dhaka from 500 taka".
The rule changes to a 50 taka base. Someone updates checkout and the receipt. The refund job still uses 40. For two weeks, refunds are 10 taka short, and customers complain that the app "stole" money.
Meanwhile, another developer, asked to make the fee "configurable", builds a FeeStrategyFactory with a plugin system, a YAML file and four interfaces. The business only ever had one rule.
Both problems are about the same thing: how much structure is the right amount. This chapter covers the rules of thumb engineers use to answer that.
The problem: too little structure, or too much
Bad design comes in two flavours:
| Too little | Too much |
|---|---|
| The same rule copied into several places | Interfaces with one implementation |
| One function that parses, calculates, formats and saves | Factories for objects with two fields |
Code that reaches through a.b().c().d() | Config files for values that never change |
| Changes need edits in many files | Changes need edits in many layers |
Both make change expensive. The principles in this chapter push from both sides toward the middle: one home for each piece of knowledge, and no more machinery than today's requirements need.
Think of a recipe book
A good restaurant keeps one recipe for its biryani. Every cook follows it. When the owner changes the spice mix, one page changes and every plate changes with it. That's DRY.
The recipe says "two cups of rice", not "a configurable quantity of a grain-type abstraction". That's KISS.
Nobody writes a recipe for a dish that isn't on the menu "in case a customer asks one day". That's YAGNI.
And a waiter asks the kitchen "is the biryani ready?", not "is the third pot on the second stove at the back hot enough yet?". That's the Law of Demeter.
Where the comparison breaks down. A recipe book is written once and rarely changes. Code changes every week, which is exactly why duplication hurts more in code: the more often something changes, the more each extra copy costs.
The delivery fee rule lives in one function, deliveryFee(). Checkout, the receipt and the refund job all call it. The base fee changes from 40 to 50 taka. How many places change?
Five rules of thumb
- DRY (Don't Repeat Yourself) is about knowledge, not text. Every business rule should have one authoritative home. Two pieces of code that look alike but mean different things (a delivery fee and a service fee that both happen to be 40 taka today) are not duplication; merging them couples rules that will change separately.
- KISS (Keep It Simple): choose the simplest design that meets today's requirements. A plain function with clear inputs beats a framework. Simple code is easier to read, test and change, including into something more complex later.
- YAGNI (You Aren't Gonna Need It): don't build for features nobody has asked for. Guessed flexibility is usually the wrong flexibility, and it has to be maintained until someone dares to delete it.
- Separation of concerns: keep different reasons to change in different places. Working out the price and formatting the receipt change for different reasons (business vs presentation), so they live in different functions.
- Law of Demeter ("talk to your friends, not to strangers"): a method should call methods on itself, its fields, its parameters, and objects it creates, not on objects it got from those.
order.customer.address.cityties the caller to three classes' shapes.order.DeliveryCity()ties it to one. - They pull against each other. DRY can push you toward abstraction; KISS and YAGNI push back. The balance: remove duplication of knowledge when you see it the second or third time, and wait for the second real case before adding an abstraction.
Refactoring the fee, step by step
From three copies to one home
Step 1/5 Find the knowledge, not the text
All three places compute 'base for 3 km, then per km, free in Dhaka from 500'. That's one business rule, written three times.
- copies
- 3
- refund job base
- 40 (stale)
Before you add or remove structure
Ask three questions:
- If this rule changes, how many places must I edit? More than one: there's duplicated knowledge (DRY).
- Can I explain this design in two sentences to a new teammate? No: it's probably too clever (KISS).
- Is there a real, current requirement for this flexibility? No: delete it or don't build it (YAGNI).
And for each method call: am I asking my friend, or reaching through my friend to a stranger? (Demeter.)
Try it: which principle is broken?
Name the principle
price * 1.15 // checkout.go, invoice.go, refund.go ...
The delivery fee in code
One home for the fee rule (DRY, KISS), pricing kept apart from formatting (separation of concerns), and DeliveryCity() so callers don't reach through the customer's address (Demeter). Four orders, and the refund job calls the same rule.
package main
import "fmt"
type Address struct{ City string }
type Customer struct {
Name string
address Address
}
type Order struct {
ID int
Subtotal int // taka
Km int
customer Customer
}
// Law of Demeter: callers ask the order, instead of reaching through
// order.customer.address.City themselves.
func (o Order) DeliveryCity() string { return o.customer.address.City }
// DRY: the delivery fee rule lives in exactly one function.
// KISS: a plain function with three inputs; no strategy, no factory, no config file.
func deliveryFee(subtotal, km int, city string) int {
if city == "Dhaka" && subtotal >= 500 {
return 0 // free delivery in Dhaka from 500 taka
}
fee := 40
if km > 3 {
fee += (km - 3) * 10
}
return fee
}
// Separation of concerns: pricing works out numbers...
func price(o Order) (fee, total int) {
fee = deliveryFee(o.Subtotal, o.Km, o.DeliveryCity())
return fee, o.Subtotal + fee
}
// ...and formatting turns numbers into text. Neither knows about the other's job.
func receipt(o Order, fee, total int) string {
return fmt.Sprintf("order %d: %d tk + delivery %d tk = %d tk (%s, %d km)",
o.ID, o.Subtotal, fee, total, o.DeliveryCity(), o.Km)
}
func main() {
dhaka := Address{City: "Dhaka"}
ctg := Address{City: "Chattogram"}
orders := []Order{
{ID: 1, Subtotal: 350, Km: 2, customer: Customer{"Rahim", dhaka}},
{ID: 2, Subtotal: 650, Km: 6, customer: Customer{"Nadia", dhaka}},
{ID: 3, Subtotal: 450, Km: 9, customer: Customer{"Tania", ctg}},
{ID: 4, Subtotal: 650, Km: 6, customer: Customer{"Sabbir", ctg}},
}
for _, o := range orders {
fee, total := price(o)
fmt.Println(receipt(o, fee, total))
}
// The checkout screen and the refund job call the same rule, so they can't disagree.
fmt.Println("refund job, order 3 delivery:", deliveryFee(450, 9, "Chattogram"), "tk")
}from dataclasses import dataclass
@dataclass(frozen=True)
class Address:
city: str
@dataclass(frozen=True)
class Customer:
name: str
address: Address
@dataclass(frozen=True)
class Order:
id: int
subtotal: int # taka
km: int
customer: Customer
def delivery_city(self) -> str:
"""Law of Demeter: callers ask the order instead of reaching through customer.address.city."""
return self.customer.address.city
def delivery_fee(subtotal: int, km: int, city: str) -> int:
"""DRY: the delivery fee rule lives in exactly one function.
KISS: a plain function with three inputs; no strategy, no factory, no config file."""
if city == "Dhaka" and subtotal >= 500:
return 0 # free delivery in Dhaka from 500 taka
return 40 + max(0, km - 3) * 10
def price(o: Order) -> tuple[int, int]:
"""Separation of concerns: pricing works out numbers..."""
fee = delivery_fee(o.subtotal, o.km, o.delivery_city())
return fee, o.subtotal + fee
def receipt(o: Order, fee: int, total: int) -> str:
"""...and formatting turns numbers into text."""
return f"order {o.id}: {o.subtotal} tk + delivery {fee} tk = {total} tk ({o.delivery_city()}, {o.km} km)"
dhaka, ctg = Address("Dhaka"), Address("Chattogram")
orders = [
Order(1, 350, 2, Customer("Rahim", dhaka)),
Order(2, 650, 6, Customer("Nadia", dhaka)),
Order(3, 450, 9, Customer("Tania", ctg)),
Order(4, 650, 6, Customer("Sabbir", ctg)),
]
for o in orders:
fee, total = price(o)
print(receipt(o, fee, total))
# The checkout screen and the refund job call the same rule, so they can't disagree.
print("refund job, order 3 delivery:", delivery_fee(450, 9, "Chattogram"), "tk")interface Address {
readonly city: string;
}
interface Customer {
readonly name: string;
readonly address: Address;
}
class Order {
readonly id: number;
readonly subtotal: number; // taka
readonly km: number;
readonly #customer: Customer;
constructor(id: number, subtotal: number, km: number, customer: Customer) {
this.id = id;
this.subtotal = subtotal;
this.km = km;
this.#customer = customer;
}
// Law of Demeter: callers ask the order instead of reaching through customer.address.city.
deliveryCity(): string {
return this.#customer.address.city;
}
}
// DRY: the delivery fee rule lives in exactly one function.
// KISS: a plain function with three inputs; no strategy, no factory, no config file.
function deliveryFee(subtotal: number, km: number, city: string): number {
if (city === "Dhaka" && subtotal >= 500) return 0; // free delivery in Dhaka from 500 taka
return 40 + Math.max(0, km - 3) * 10;
}
// Separation of concerns: pricing works out numbers...
function price(o: Order): [fee: number, total: number] {
const fee = deliveryFee(o.subtotal, o.km, o.deliveryCity());
return [fee, o.subtotal + fee];
}
// ...and formatting turns numbers into text.
function receipt(o: Order, fee: number, total: number): string {
return `order ${o.id}: ${o.subtotal} tk + delivery ${fee} tk = ${total} tk (${o.deliveryCity()}, ${o.km} km)`;
}
const dhaka: Address = { city: "Dhaka" };
const ctg: Address = { city: "Chattogram" };
const orders = [
new Order(1, 350, 2, { name: "Rahim", address: dhaka }),
new Order(2, 650, 6, { name: "Nadia", address: dhaka }),
new Order(3, 450, 9, { name: "Tania", address: ctg }),
new Order(4, 650, 6, { name: "Sabbir", address: ctg }),
];
for (const o of orders) {
const [fee, total] = price(o);
console.log(receipt(o, fee, total));
}
// The checkout screen and the refund job call the same rule, so they can't disagree.
console.log("refund job, order 3 delivery:", deliveryFee(450, 9, "Chattogram"), "tk");struct Address { city: String }
struct Customer { _name: String, address: Address }
struct Order { id: u32, subtotal: u32, km: u32, customer: Customer }
impl Order {
// Law of Demeter: callers ask the order instead of reaching through customer.address.city.
fn delivery_city(&self) -> &str { &self.customer.address.city }
}
// DRY: the delivery fee rule lives in exactly one function.
// KISS: a plain function with three inputs; no strategy, no factory, no config file.
fn delivery_fee(subtotal: u32, km: u32, city: &str) -> u32 {
if city == "Dhaka" && subtotal >= 500 {
return 0; // free delivery in Dhaka from 500 taka
}
40 + km.saturating_sub(3) * 10
}
// Separation of concerns: pricing works out numbers...
fn price(o: &Order) -> (u32, u32) {
let fee = delivery_fee(o.subtotal, o.km, o.delivery_city());
(fee, o.subtotal + fee)
}
// ...and formatting turns numbers into text.
fn receipt(o: &Order, fee: u32, total: u32) -> String {
format!("order {}: {} tk + delivery {fee} tk = {total} tk ({}, {} km)", o.id, o.subtotal, o.delivery_city(), o.km)
}
fn main() {
let order = |id, subtotal, km, name: &str, city: &str| Order {
id, subtotal, km,
customer: Customer { _name: name.into(), address: Address { city: city.into() } },
};
let orders = [
order(1, 350, 2, "Rahim", "Dhaka"),
order(2, 650, 6, "Nadia", "Dhaka"),
order(3, 450, 9, "Tania", "Chattogram"),
order(4, 650, 6, "Sabbir", "Chattogram"),
];
for o in &orders {
let (fee, total) = price(o);
println!("{}", receipt(o, fee, total));
}
// The checkout screen and the refund job call the same rule, so they can't disagree.
println!("refund job, order 3 delivery: {} tk", delivery_fee(450, 9, "Chattogram"));
}import java.util.List;
public class Main {
record Address(String city) {}
record Customer(String name, Address address) {}
record Order(int id, int subtotal, int km, Customer customer) {
// Law of Demeter: callers ask the order instead of reaching through customer().address().city().
String deliveryCity() { return customer.address().city(); }
}
// DRY: the delivery fee rule lives in exactly one method.
// KISS: a plain method with three inputs; no strategy, no factory, no config file.
static int deliveryFee(int subtotal, int km, String city) {
if (city.equals("Dhaka") && subtotal >= 500) return 0; // free delivery in Dhaka from 500 taka
return 40 + Math.max(0, km - 3) * 10;
}
record Price(int fee, int total) {}
// Separation of concerns: pricing works out numbers...
static Price price(Order o) {
int fee = deliveryFee(o.subtotal(), o.km(), o.deliveryCity());
return new Price(fee, o.subtotal() + fee);
}
// ...and formatting turns numbers into text.
static String receipt(Order o, Price p) {
return String.format("order %d: %d tk + delivery %d tk = %d tk (%s, %d km)",
o.id(), o.subtotal(), p.fee(), p.total(), o.deliveryCity(), o.km());
}
public static void main(String[] args) {
Address dhaka = new Address("Dhaka"), ctg = new Address("Chattogram");
List<Order> orders = List.of(
new Order(1, 350, 2, new Customer("Rahim", dhaka)),
new Order(2, 650, 6, new Customer("Nadia", dhaka)),
new Order(3, 450, 9, new Customer("Tania", ctg)),
new Order(4, 650, 6, new Customer("Sabbir", ctg)));
for (Order o : orders) System.out.println(receipt(o, price(o)));
// The checkout screen and the refund job call the same rule, so they can't disagree.
System.out.println("refund job, order 3 delivery: " + deliveryFee(450, 9, "Chattogram") + " tk");
}
}#include <algorithm>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
struct Address {
std::string city;
};
struct Customer {
std::string name;
Address address;
};
class Order {
public:
Order(int id, int subtotal, int km, Customer c) : id(id), subtotal(subtotal), km(km), customer_(std::move(c)) {}
int id, subtotal, km; // subtotal in taka
// Law of Demeter: callers ask the order instead of reaching through customer.address.city.
const std::string& deliveryCity() const { return customer_.address.city; }
private:
Customer customer_;
};
// DRY: the delivery fee rule lives in exactly one function.
// KISS: a plain function with three inputs; no strategy, no factory, no config file.
int deliveryFee(int subtotal, int km, const std::string& city) {
if (city == "Dhaka" && subtotal >= 500) return 0; // free delivery in Dhaka from 500 taka
return 40 + std::max(0, km - 3) * 10;
}
// Separation of concerns: pricing works out numbers...
std::pair<int, int> price(const Order& o) {
int fee = deliveryFee(o.subtotal, o.km, o.deliveryCity());
return {fee, o.subtotal + fee};
}
// ...and formatting turns numbers into text.
std::string receipt(const Order& o, int fee, int total) {
char buf[128];
std::snprintf(buf, sizeof buf, "order %d: %d tk + delivery %d tk = %d tk (%s, %d km)", o.id, o.subtotal, fee,
total, o.deliveryCity().c_str(), o.km);
return buf;
}
int main() {
Address dhaka{"Dhaka"}, ctg{"Chattogram"};
std::vector<Order> orders{
{1, 350, 2, {"Rahim", dhaka}},
{2, 650, 6, {"Nadia", dhaka}},
{3, 450, 9, {"Tania", ctg}},
{4, 650, 6, {"Sabbir", ctg}},
};
for (const auto& o : orders) {
auto [fee, total] = price(o);
std::printf("%s\n", receipt(o, fee, total).c_str());
}
// The checkout screen and the refund job call the same rule, so they can't disagree.
std::printf("refund job, order 3 delivery: %d tk\n", deliveryFee(450, 9, "Chattogram"));
}All six print exactly this:
order 1: 350 tk + delivery 40 tk = 390 tk (Dhaka, 2 km)
order 2: 650 tk + delivery 0 tk = 650 tk (Dhaka, 6 km)
order 3: 450 tk + delivery 100 tk = 550 tk (Chattogram, 9 km)
order 4: 650 tk + delivery 70 tk = 720 tk (Chattogram, 6 km)
refund job, order 3 delivery: 100 tk
Reading the output:
- Order 1: 2 km is within the first 3 km, so the base 40 taka.
- Order 2: Dhaka and 650 ≥ 500, so free delivery, even at 6 km.
- Order 3: Chattogram, 9 km: 40 + (9 − 3) × 10 = 100.
- Order 4: same subtotal and distance as order 2, but outside Dhaka, so 40 + 30 = 70. The free-delivery rule depends on the city, which is why the fee function takes the city as an input instead of looking it up itself.
- The refund job prints 100 for order 3, the same as checkout. It can't drift, because there's nothing to drift from.
- What each language changed: very little, which is the point: a plain function is the same idea everywhere. Java and Python use records/frozen dataclasses so the order can't be edited by accident; TypeScript and C++ hide the customer behind a private field so
DeliveryCity()is the only way to get the city.
Using these principles on a real team
- The rule of three: tolerate a second copy if you're unsure; when the third appears, extract it. You'll know much more about what's really shared by then.
- Duplication across services is sometimes fine. Two microservices owned by different teams may each keep a small copy of a rule rather than share a library that couples their releases. DRY is strongest inside one codebase.
- Delete speculative code. If a "future" extension point has had one implementation for a year, inline it. Less code is less to read.
- Demeter isn't about counting dots.
builder.WithA().WithB()andlist.Filter().Map()chain calls on the same object or on fresh values; that's fine. The smell is walking into other objects' internals. - Write the fee as a pure function and test it with a table. It takes values and returns a value, so dozens of cases run in milliseconds.
Designs that cause problems later
Pick the design that will hurt
fee := 40 + max(0, km-3)*10 // partner_api.go
Pause and think
Your lead says: "Make deliveryFee pluggable now. Marketing might want weekend pricing someday." There's no weekend pricing on the roadmap.
Reveal
Keep it a plain function for now (YAGNI). When weekend pricing becomes a real requirement, you'll add the day as an input, or, if several pricing schemes really appear, turn the function into a strategy then. Because the rule already has exactly one home and pure inputs, that change will be small and safe. That's the quiet benefit of DRY plus KISS: they make the later abstraction cheap, so you don't have to build it early.
Quick quiz
1. DRY is about not repeating what, exactly?
Answer
Knowledge: business rules and decisions. Two blocks of similar-looking code that represent different rules aren't duplication and shouldn't be merged.
2. How do KISS and YAGNI differ?
Answer
KISS is about how you build what's needed: as simply as possible. YAGNI is about what you build: only what's needed now, not guessed future features.
3. Why is order.customer.address.city worse than order.DeliveryCity()?
Answer
The caller depends on the internal shape of three classes. If Customer or Address changes, every such caller breaks. With DeliveryCity(), only Order needs to know.
4. Why does deliveryFee take the city as a parameter instead of reading it itself?
Answer
It keeps the rule pure and separate from where data lives (separation of concerns), so it can be tested with plain values and reused by any caller, including the refund job.
Your challenge: a weekend rule, done simply
Marketing now really wants it: on Fridays, delivery is free in every city from 500 taka.
- Add the day as an input to
deliveryFee, the same way the city is. Don't add a new class or interface. - Re-run the four orders for a Friday at the end of the story.
Before you run it, predict order 4 on a Friday.
You've succeeded when order 4 on Friday prints order 4: 650 tk + delivery 0 tk = 650 tk (Chattogram, 6 km), the original four lines and the refund line are unchanged, and the rule still lives only in deliveryFee.
In one line
Give every piece of knowledge exactly one home (DRY), build the simplest thing that works today (KISS) and nothing for imagined futures (YAGNI), keep different reasons to change apart, and ask your direct friends instead of reaching through them. Clean, simple code is what makes tomorrow's abstraction cheap.
Station stamp
Finish the chapter
Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.
Go Pro to earn XP →