Low-Level Design: Class Relationships and Design Patterns in Go and Python
Contents
Low-level design rests on two skills: telling how two classes are related, and recognizing design patterns, which are those same relationships arranged on purpose to solve a problem that keeps coming back. This post consists of six parts. Part 1 and Part 2 cover relationships, with 100 practice questions. Parts 3 to 5 (creational, structural, behavioral) cover the patterns, each with a class diagram, Go code (Python where the idiom differs), the APIs you already use that follow it, and the traps. Part 6 is a field guide for when you have a real problem and need the right pattern.
Each part ends with a quiz. Answers are saved in this browser, so you can revisit after few days and re-run them cold. The Go samples need Go 1.21 or later (1.23 for iter and unique); the Python samples need 3.10 or later.
Disclaimer. This post is drafted with assistance from large language models (Claude Opus 5.5 and DeepSeek V4.1 Flash) based on conversations exploring low-level design and design patterns. All content has been reviewed, edited, and verified by a human author.
Part 1: Association, aggregation or composition?
Most low-level design problems, from parking lots to movie booking, keep asking one question: how are these two classes related? Association, aggregation and composition look identical in code. What separates them is ownership and lifetime. Get that call right and the rest of the design follows: who constructs what, what a delete cascades to, who may close a connection, and what a lock actually protects.
The two questions that separate them
In code, all three are a field in class A that refers to class B. Two questions about ownership and lifetime tell them apart:
- If I delete
A, what happens toB? - Can
Bbelong to more than oneAat the same time?
| Whole–part? | B survives deleting A? |
B shareable? |
|
|---|---|---|---|
| Association | No, they’re peers | Yes | Yes, often many-to-many |
| Aggregation | Yes | Yes | Yes, or it can move between wholes |
| Composition | Yes | No, it dies with A |
No, exactly one owner |
Three examples from common interview problems:
- Association. A parking lot’s
VehicleandParkingSpot: the car existed before it parked and drives away after. - Aggregation. Splitwise’s
GroupandUser: delete the trip group and your friends keep their accounts. - Composition. Movie booking’s
ScreenandSeat: seat A9 means nothing without its screen.
The full spectrum and its notation
Those three sit in the middle of a wider range of relationships, ordered by how tightly the two classes are coupled:
The middle three are the ones most designs argue about. Realization, implementing an interface, sits level with inheritance: the same hollow triangle, on a dashed line.
How to read the notation:
- The diamond always sits on the whole’s end. Hollow ◇ is aggregation, filled ◆ is composition. In plain text you’ll see them typed as
A <>-- BandA <#>-- B. - A plain solid line is an association. Multiplicities such as
1and0..*go on its ends. - A dashed open arrow is a dependency.
AusesBsomewhere in its code but doesn’t keep it. - A hollow triangle points at a parent type. A solid line means inheritance from a class; a dashed line means realization of an interface.
- An association class hangs off the middle of an association line by a dashed line.
Spotting them in code and schemas
In code, the tell is who calls B’s constructor, and whether B is handed in or created inside.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Association: peers created independently, linked by reference
class Teacher:
def __init__(self, name: str):
self.name = name
self.students: list["Student"] = []
def enroll(self, s: "Student"):
self.students.append(s)
s.teachers.append(self) # often bidirectional
# Aggregation: the whole holds parts that were created elsewhere
class Library:
def __init__(self):
self.books: list[Book] = []
def add(self, book: Book): # passed in: the caller created it
self.books.append(book)
# Composition: the whole creates its parts and never hands out ownership
class House:
def __init__(self, room_specs):
self._rooms = [Room(s) for s in room_specs] # created inside
In a garbage-collected language nothing is destroyed when A goes away; B is freed only once nothing points to it. So composition is a design rule the runtime won’t enforce: A alone creates and manages B, and no other object keeps a reference to B after A is gone. Other code may borrow B during a call, but it shouldn’t store it.
The database view makes the difference concrete, and interviewers do probe it:
| Relationship | Schema shape | On delete of A |
|---|---|---|
| Association | Join table, such as teacher_student |
Delete only the join rows |
| Aggregation | Nullable foreign key on B, such as book.library_id |
ON DELETE SET NULL |
| Composition | NOT NULL foreign key on B, such as room.house_id |
ON DELETE CASCADE |
| Association class | Join table with its own columns, such as enrollment(student_id, course_id, grade) |
Delete the link rows, and their data goes with them; or RESTRICT the delete if that data must be kept |
In Go. Composition is a struct that creates and holds its parts, often as value fields or unexported pointers. Aggregation is a struct holding pointers injected through its constructor. Struct embedding isn’t inheritance: it’s composition with method promotion. And the advice “favor composition over inheritance” uses the word loosely, meaning “hold an object instead of subclassing”, which covers aggregation and association too.
Association versus aggregation is the blurriest line. UML leaves aggregation’s meaning loose, and Martin Fowler’s UML Distilled recommends essentially ignoring it. The distinction that matters most is composition versus everything else, because it decides cascading deletes, who constructs what, and whether a part can be shared.
Dependency or association?
These two get confused more than any other pair. One question settles it: does A still hold a reference to B after the method returns?
- Yes: association.
Bis part ofA’s state, stored in a field. - No: dependency.
AtouchesBonly while one method runs, through a parameter, a local variable, a return value or a static call.
Put another way, an association is a link between objects, and a dependency is a link between pieces of code. In lot.park(car), the lot reaches the car only through the parameter, then builds a Ticket that stores it in a field. So ParkingLot → Vehicle is a dependency and Ticket → Vehicle is an association: the same Vehicle in two relationships, decided by who keeps the reference.
The snapshot test. Freeze the program at a moment when no method is running and look at the object graph. Every arrow you can still see is an association. Dependencies never show up in a snapshot, because they exist only while code is running.
1
2
3
4
5
6
7
8
9
10
11
# Dependency: A uses B, then forgets it
class ParkingLot:
def park(self, vehicle: Vehicle) -> Ticket: # parameter
spot = self._find_spot(vehicle.size)
return Ticket(spot, vehicle) # creates and returns, keeps nothing
# Association: A keeps B
class Ticket:
def __init__(self, spot: ParkingSpot, vehicle: Vehicle):
self.spot = spot # stored
self.vehicle = vehicle # stored
On a class diagram, fields draw solid lines and method signatures draw dashed ones.
Trace each line back to the member it comes from. Fields, which Order keeps, draw solid lines; types that only appear in a method signature draw dashed arrows.
| Dependency | Association | |
|---|---|---|
| Lives in | Parameters, locals, return types, static calls | Fields (instance state) |
| Link lasts | One method call | As long as A holds the field |
| UML | Dashed arrow | Solid line |
| Database | Nothing in the schema | Foreign key or join table |
| Coupling | Weaker; prefer it when you can | Stronger |
The gray areas
- Injected services.
OrderService.__init__(self, repo)that storesself.repois an association by the strict test. Many teams still draw injected collaborators as dashed dependencies to keep only domain relationships solid. Either is defensible if you state your convention; this post uses the strict test. - Storing an ID.
Order.customer_idis still an association at the domain level. The ID is just how the link is stored. - Create and return. A factory that builds a
Carand returns it depends onCar(UML even has a«create»dependency) but keeps nothing. - The same pair can be either.
Report.print(printer)is a dependency; aReportholdingself.printeris an association. The code decides, not the class names.
Two design signals come out of this. If you pass the same object into most of a class’s methods, it’s probably state: make it a field. If a field is used by only one method, it probably isn’t state: make it a parameter. Less state means fewer lifecycle and concurrency problems, so default to dependency and promote to association only when A genuinely needs to remember B.
When the link has data
Sometimes the relationship itself carries data. A grade belongs neither to the student nor to the course, but to the pairing. The quantity of a product belongs neither to the cart nor to the product. When the link has attributes, promote it to a class of its own, an association class: Enrollment, CartItem, Membership.
- Parking lot: the spot–vehicle link carries an entry time and a fee, so it becomes
Ticket. - Movie booking:
Screen ◆ Seatis composition, but “seat A9 for the 7 pm show” is a different thing:ShowSeat, the association class betweenShowandSeatthat holds booking status and price. It’s also what gets locked when two people try to book the same seat.
In a database, an association class is a join table with its own columns. One subtlety: strictly, a UML association class allows one link per pair. If the same pair can repeat, such as the same song twice in one playlist, model the link as a full class, or mark the association ends {nonunique}.
The decision tree
Everything above folds into five questions, asked in order. Stop at the first one that gives an answer. The order is for classification, not preference: inheritance comes first because it’s the easiest to rule in or out.
Highlighted outcomes are the stored-link family; the rest are type relationships or transient use.
What to look for at each step
| Step | Signals in code and schemas |
|---|---|
| 1. Kind of, or implements? | extends or a subclass, including an abstract class with concrete methods → inheritance. implements, a Python Protocol, a Go method set, or Rust impl Trait for → realization. |
| 2. Stored? | A field of any kind, including injected services, IDs, weak references and borrowed references → stored. A parameter, local, return type, thrown exception, static or module-level call, or something created and returned → dependency. |
| 3. Link has data? | Data that belongs to neither side alone. A join table with extra columns. |
| 4. Whole–part? | Is A made of B? Possessive English isn’t enough: a doctor “has” patients but isn’t made of them. |
| 5. Outlive or shared? | Composition: created inside, private, a value field, unique_ptr, Box, NOT NULL plus CASCADE. Aggregation: passed in, the same instance in several wholes, shared_ptr, a nullable foreign key with SET NULL. |
The conventions used throughout this post, so that every question has one defensible answer:
- Injected services stored in a field count as associations (the strict test).
- Storing an ID instead of an object is still an association.
- Value fields,
unique_ptrandBoxare composition; Go struct embedding by value is composition, not inheritance. - Composition fixes when parts must die, not when they may: deleting the whole deletes its remaining parts, but the whole can delete a part earlier, such as removing one line item from an order. That’s still composition; UML allows it.
- Domain rules beat class names.
Car → Engineis composition at a carmaker and aggregation at an engine-refurbishing shop.
What the decision buys you
Classifying relationships is the vocabulary. What you use it for is the decision every design comes down to: who creates each object, who is allowed to destroy it, and who else can reach it. Once you’ve chosen the relationship, a set of code decisions follows:
A → B is… |
Who creates B |
Who cleans B up |
Database | Locking |
|---|---|---|---|---|
| Dependency | The caller, per call | The caller | Nothing | Nothing held, nothing to guard |
| Association | Someone else; injected | B’s real owner, never A |
Foreign key on the “many” side (often A, such as ticket.vehicle_id) or a join table; no cascade |
B is shared, so it must be safe on its own |
| Aggregation | Outside, then added to A |
Outside; B can outlive A |
Nullable foreign key on B pointing to A, SET NULL |
A’s lock doesn’t cover B: other wholes can reach it |
| Composition | A, in its constructor |
A, when it closes or is deleted |
NOT NULL foreign key on B pointing to A, CASCADE |
A’s lock can guard B; nobody else holds it |
| Association class | Whoever creates the link | Removed with the link | Join table with foreign keys to both sides, plus its own columns | Often the row you lock, such as ShowSeat |
Two rules from that table come up constantly:
- Only close what you composed. If a struct receives a
*sql.DBor a*grpc.ClientConnthrough its constructor, that’s an association, and it must never callClose()on it. The owner does. - A lock on the whole protects only its composed parts. If a part can be shared, another whole can reach it without taking your lock, so the part needs its own protection.
Where it fits in a design
Relationships are step 3 of a repeatable low-level design process:
- Clarify requirements. Four to six use cases as verbs (“park vehicle”, “exit and pay”). Ask only questions that change the design.
- Identify entities. The nouns in those use cases, minus attributes posing as classes: a vehicle’s color is a field.
- Map relationships. Run the decision tree on each pair, and promote links that carry data to association classes.
- Assign behavior. Give each verb to the class that already has the data it needs.
- Model state. Anything with a lifecycle gets an enum and its legal transitions, such as
ShowSeat: AVAILABLE → LOCKED → BOOKED. - Apply patterns where things vary. Pricing rules suggest Strategy; spot or vehicle types suggest Factory.
- Handle concurrency. Two cars, one spot: pick a per-entity lock, an optimistic version check or a unique constraint, and make payment idempotent.
- Code and walk through. Interfaces, core classes and one end-to-end method such as
park(), then trace a use case through them.
Step 3 for a parking lot
Most of the relationship types in one picture. Ticket, in orange, is the association class: the spot–vehicle link promoted to a class of its own because it carries data.
ParkingLot ◆ Floor ◆ ParkingSpotis composition all the way down. The lot builds floors and spots from configuration, and spot “F2-17” means nothing without them.Ticketis the spot–vehicle link promoted to an association class: it carries an entry time and a fee, and the car exists before and after it parks.ParkingLot — PricingStrategyis a plain association: injected and swappable, but a lot isn’t made of its pricing rules. It’s also the Strategy pattern.CarandTruckinherit fromVehicle. If they differ only in size, aVehicleTypeenum is simpler; subclass only when behavior differs.
The usual traps: labelling everything composition because “the lot has spots”, spending twenty minutes on aggregation versus association, and running out of time before state and concurrency, which is where strong answers stand out.
A real-world call: who owns the instance?
Relationship thinking settles real infrastructure questions too. Say a service keeps a pool of pre-warmed virtual machines in an AWS Auto Scaling group, hands one out per customer request, applies the customer’s configuration, and terminates the machine when the customer is done. Should the instance be detached from the group once it’s assigned?
The question is really which object should compose the instance once it’s assigned:
- If you don’t detach, the group still owns it. A scale-in event or a failed health check can terminate a customer’s live machine. Instance scale-in protection stops the first, but not health-check replacement.
- If you detach, ownership moves to the site. That’s still composition, because a part can leave its whole before the whole is deleted. The duties move with ownership: the service must now terminate the instance when the site is discarded, and replace it if it fails.
- Detaching can also refill the pool. Detach without decrementing the group’s desired capacity and the group launches a replacement.
Check your understanding
-
1
Class
Ahas a field of typeB. Which facts decide whether that’s an association, an aggregation or a composition?Show answer
Answer What happens to
BwhenAis deleted, and whetherBcan belong to more than oneAThe code looks the same in all three cases. Ownership and lifetime separate them: if
Bdies withAand has exactly one owner, it’s composition; if it’s a part that survives or can be shared, aggregation; if the two are peers, association. Visibility and collection types are hints at most. -
2
You freeze a running program at a moment when no method is executing, and look at the object graph. Which relationships can you still see?
Show answer
Answer Associations, including aggregations and compositions, but no dependencies
This is the snapshot test. A dependency exists only while code runs, through a parameter, a local or a return value, so it disappears with the stack frame. Anything stored in a field is still there. Inheritance and realization aren’t links between objects at all; they’re relationships between types.
-
3
ParkingLot.park(vehicle)finds a spot, createsTicket(spot, vehicle)and returns it. The lot stores nothing about the vehicle; the ticket keeps it in a field. What areParkingLot → VehicleandTicket → Vehicle?Show answer
Answer Dependency, then association
Same
Vehicle, two relationships, decided by who keeps the reference. The lot reaches the car only through a parameter for the length of one call, so that’s a dependency. The ticket stores it in a field that outlives the call, so that’s an association. -
4
A class receives a
Clockas a parameter of one method and never stores it. At which step does the decision tree stop?Show answer
Answer Step 2, “is the link stored?”, with dependency
The class isn’t a kind of
Clockand doesn’t implement it, so step 1 says neither. Step 2 asks whether the link still exists after the method returns. It doesn’t, so the answer is dependency, and the questions about data, whole–part and lifetime never come up. -
5
A student’s grade in a course belongs to neither the student nor the course. Where should it live?
Show answer
Answer In an association class such as
Enrollment, which links one student to one courseData that belongs to the pairing, not to either side, is the signal for an association class.
Enrollmentcarries the grade, and in a database it’s a join table with its own columns. A map on either side works, but it hides the link and duplicates it if both sides need it. -
6
A playlist can contain the same song more than once. Why is a plain UML association class between
PlaylistandSongnot quite right?Show answer
Answer A UML association class allows only one link per pair, so a repeated song needs a full class such as
PlaylistEntry, or ends marked{nonunique}Strictly, an association class instance is identified by the pair it links, so the same pair can’t appear twice. If repeats are allowed, make the link a class with its own identity, or mark the association ends
{nonunique}. -
7
Which schema shape matches composition, such as a
roomrow that belongs to ahouse?Show answer
Answer A
NOT NULLforeign keyroom.house_idwithON DELETE CASCADENOT NULLsays a room can’t exist without a house, andCASCADEdeletes the rooms with it. A nullable foreign key withSET NULLis aggregation: the part survives, unattached. A join table is a plain association. A dependency leaves no trace in the schema. -
8
A
Repostruct receives a*sql.DBpool through its constructor and stores it. ShouldRepo.Close()calldb.Close()?Show answer
Answer No. It’s an association, and whoever created the pool closes it.
Only close what you composed. The repo didn’t create the pool and other code may share it, so closing it would break every other user. The owner, usually
main, closes it once at shutdown. -
9
An
Orderguards its state with a mutex. It composes itsLineItems and holds a reference to a sharedCustomer. What does taking the order’s lock protect?Show answer
Answer The order and its line items, but not the customer
A lock on the whole protects only what nobody else can reach. The line items are composed, so every path to them goes through the order and its lock. The customer is shared: other orders, and other code, can reach it without taking this lock, so it needs its own protection.
-
10
In Go,
type Car struct { Engine }embeds anEnginestruct by value. What relationship is that?Show answer
Answer Composition with method promotion
Embedding by value puts a whole
Engineinside everyCar, created with it and gone with it, so it’s composition. Go promotes the engine’s methods so you can call them on the car, but aCaris never usable where anEngineis expected, so it isn’t inheritance. -
11
In the parking lot model, why is
ParkingLot — PricingStrategya plain association rather than a composition?Show answer
Answer The strategy is injected and can be swapped, and a lot isn’t made of its pricing rules
The lot stores the strategy, so it’s at least an association. The whole–part question fails: pricing rules aren’t a part of the lot the way floors are, and the same strategy object could serve several lots. Nothing stops a field of interface type from being composed, if the whole creates and owns it.
-
12
Why do many designers stop arguing about association versus aggregation?
Show answer
Answer UML defines aggregation loosely, and composition versus everything else is the line that changes the code
UML gives aggregation almost no semantics beyond association, and Fowler’s UML Distilled recommends ignoring it. Composition is different: it decides cascading deletes, who constructs the part, and whether the part can be shared. Pick one, state your lifecycle reasoning, and move on.
-
13
A service hands customers instances from an Auto Scaling group and doesn’t detach them, but turns on instance scale-in protection. What can still terminate a customer’s live machine?
Show answer
Answer The group replacing the instance after it fails a health check
Scale-in protection only stops the group choosing the instance during scale-in. An instance that fails its health check is still replaced. While the group composes the instance, the group decides when it dies; detaching moves that ownership, and the duty to terminate and replace it, to the site.
Part 2: 100 questions on class relationships
Running the decision tree quickly on code you’ve never seen takes practice. These 100 questions cover code in Python, Go, Java, TypeScript, C++ and Rust, SQL schemas, UML and real systems. Pairs that differ by one fact recur, so you practice the deciding detail rather than the class names. Answers follow the conventions from Part 1; where a second answer is defensible, it’s marked as also accepted. Walk the tree mode asks the five questions one at a time and shows which step went wrong.
What is the relationship?
- Dependency uses it, keeps nothing
- Association stored link between peers
- Association class the link has its own data
- Aggregation part survives or is shared
- Composition exclusive, dies with the whole
- Inheritance is a kind of
- Realization implements a contract
- Is A a kind of B, or does A implement B's contract?
- No
- Yes, extends a class
- Yes, implements an interface
- Is the link stored, so it still exists after the method returns?
- Yes
- No
- Does the link itself carry data (quantity, status, dates, role)?
- Yes
- No
- Is it whole–part: is A made of B?
- Yes
- No
- Can the part outlive the whole, or be shared by another whole?
- Yes
- No
-
1Chessboard → Square
A chess engine models its board like this.
Python1 2 3 4 5 6 7
class Chessboard: def __init__(self): self._squares = [[Square(r, c) for c in range(8)] for r in range(8)] def square_at(self, r, c) -> "Square": return self._squares[r][c]
Show answer
Answer Composition
- Deciding clue
- The board builds its own squares in
__init__, keeps them private, and a square like “e4” means nothing without a board. - Why
- Nothing outside the board ever creates a Square, the board never accepts squares from a caller, and the squares go when the board goes.
square_at()lends a square out for reading, which doesn’t transfer ownership. - Tempting wrong turn
- Aggregation is tempting because
square_at()exposes the squares. Exposing a part for reading isn’t the same as letting it live independently or be shared by another board.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
2Playlist → Song
Songs live in the catalog. A playlist is an ordered collection of songs; a song can appear in thousands of playlists, and deleting a playlist never deletes a song.
Show answer
Answer Aggregation
- Deciding clue
- A collection whose members exist independently and are shared.
- Why
- A playlist is meaningfully “made of” songs (whole–part), but the songs outlive it and are shared across playlists. Classic aggregation.
- Tempting wrong turn
- Composition is tempting because a playlist is nothing but its songs. The test isn’t how much of the whole the parts make up, it’s whether the parts survive.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
3Employee → Employee (manager)
An org chart stores each employee’s manager.
Python1 2 3 4
class Employee: def __init__(self, name: str, manager: "Employee | None"): self.name = name self.manager = manager
Show answer
Answer Association
- Deciding clue
- A stored reference to an independent peer; neither is part of the other.
- Why
- Same class on both ends makes it a reflexive (self) association. The link is held in a field, so it isn’t a dependency. A manager isn’t made of their reports, and firing a manager doesn’t delete anyone, so it isn’t whole–part.
- Tempting wrong turn
- Aggregation is tempting because a manager “has” a team. In English “has” is cheap; ask whether the team is structurally a part of the manager object. It isn’t.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
4Document → Comment
Each comment is anchored to a range of text in one document. Deleting the document deletes all of its comments, and there’s no way to move a comment to a different document.
Show answer
Answer Composition
- Deciding clue
- Comments die with the document and can’t move to another one.
- Why
- It’s whole–part (a document’s comment thread is part of the document), the part can’t outlive the whole, and it can’t be shared. Both lifecycle questions point to composition.
- Tempting wrong turn
- Association is tempting because comments have their own authors and timestamps. Having attributes doesn’t make something independent; its lifetime does.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
5FantasyTeam → Player
In a fantasy league, the same real-world player can be drafted onto many different users’ teams.
Python1 2 3 4 5 6 7
class FantasyTeam: def __init__(self, owner: User): self.owner = owner self.roster: list[Player] = [] def draft(self, player: Player): self.roster.append(player) # from the league's player pool
Show answer
Answer Aggregation
Also accepted Association. The aggregation/association line is blurry for membership collections. Aggregation is preferred because a fantasy team is defined by its roster.
- Deciding clue
- A roster is a whole made of players, but each Player comes from a shared pool and appears on many teams.
- Why
- Whole–part: a team is its roster. The part is created elsewhere (the league pool), passed in, survives the team being deleted, and is shared across teams. That’s aggregation.
- Tempting wrong turn
- Composition is tempting because the team “has” its roster. But deleting a fantasy team obviously doesn’t delete the real player.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
6Ride → Driver
A Ride records which driver accepted it. Driver accounts exist long before and after any single ride, and one driver completes thousands of rides.
Show answer
Answer Association
- Deciding clue
- The ride keeps a lasting reference, but a driver is not a part of a ride.
- Why
- Held after the method returns: yes (the ride stores its driver). Whole–part: no. That leaves association.
- Tempting wrong turn
- Aggregation is tempting because a ride “has” a driver. A ride isn’t assembled from drivers; the driver is a participant, not a component.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
7Server → sync.Mutex
A TCP server guards its connection map with a mutex.
Go1 2 3 4 5 6 7 8 9 10
type Server struct { mu sync.Mutex conns map[string]*Conn } func (s *Server) add(id string, c *Conn) { s.mu.Lock() defer s.mu.Unlock() s.conns[id] = c }
Show answer
Answer Composition
- Deciding clue
- mu is a value field, not a pointer, so the mutex is physically laid out inside the Server’s memory.
- Why
- A value field is created with the struct, destroyed with it, and can’t be shared. Copying a Server would copy the mutex, which go vet flags as a bug. That is composition in its purest form: the part is literally inside the whole.
- Tempting wrong turn
- Dependency is tempting because the mutex is “just used” for locking. But it’s stored as state: the strict test says it is still held after every method returns, so it isn’t a dependency.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
8Target group → EC2 instance
An ALB target group lists the instances that receive traffic. Deleting the target group doesn’t terminate any instance, and one instance can be registered in several target groups.
Show answer
Answer Aggregation
Also accepted Association. Defensible if you see the target group as a routing rule that references instances rather than a collection made of them. Most designers read “group” as a whole made of members, hence aggregation.
- Deciding clue
- A group made of members that exist independently and can belong to several groups.
- Why
- The target group is defined by its membership (whole–part), yet its members outlive it and are shared. Membership collections like this are the canonical aggregation.
- Tempting wrong turn
- Composition is tempting because the target group “contains” instances. But terminating the group never terminates the instance.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
9Scheduler → Clock
A scheduler decides which jobs are due.
Go1 2 3 4 5 6 7 8 9 10 11 12 13 14
type Scheduler struct { jobs []Job } func (s *Scheduler) Due(c Clock) []Job { now := c.Now() var due []Job for _, j := range s.jobs { if !j.NextRun.After(now) { due = append(due, j) } } return due }
Show answer
Answer Dependency
- Deciding clue
- Clock appears only as a parameter; the struct has no Clock field.
- Why
- After
Due()returns, the scheduler holds no reference to the clock. The link exists only for the duration of one call. - Tempting wrong turn
- Association is tempting because the scheduler clearly needs a clock to function. Needing something isn’t the same as keeping it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
10Order → OrderLine
What relationship does this schema encode?
SQL1 2 3 4 5 6 7 8
CREATE TABLE order_lines ( order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, line_no INT NOT NULL, sku TEXT NOT NULL, qty INT NOT NULL, PRIMARY KEY (order_id, line_no) );
Show answer
Answer Composition
- Deciding clue
- The parent’s id is part of the child’s primary key, the FK is NOT NULL, and deletes cascade.
- Why
- A line’s identity literally includes its order (“order 42, line 3”). NOT NULL rules out orphan lines, and ON DELETE CASCADE means lines die with their order. This is the textbook relational shape of composition.
- Tempting wrong turn
- Association class is tempting because
order_linescarries extra columns. But it hangs off one parent, not between two independent entities, so it’s a part, not a link.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
11Department → Employee
When a department is dissolved, its employees are moved to other departments.
Python1 2 3 4 5 6 7
class Department: def __init__(self, name: str, employees: list[Employee]): self.name = name self.employees = employees hr = Department("HR", [alice, bob]) eng = Department("Platform", [carol])
Show answer
Answer Aggregation
Also accepted Association. Accepted: membership collections sit on the blurry aggregation/association line. Aggregation is preferred because the department is defined by its members.
- Deciding clue
- Employees are created outside, passed in, and survive the department.
- Why
- A department is made up of its people (whole–part), but employees outlive it and can move between departments. Passed-in parts that survive the whole: aggregation.
- Tempting wrong turn
- Association is tempting, and the line between the two is genuinely blurry. The reason to prefer aggregation here is that the department is defined by its members, not merely acquainted with them.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
12OrderPrinter → Formatter
OrderPrinter has a field
self.formatter, but onlyprint_receipt()uses it. You refactor toprint_receipt(self, order, formatter)and delete the field. What is the relationship after the refactor?Show answer
Answer Dependency
- Deciding clue
- The field is gone; the formatter now arrives as a parameter.
- Why
- Moving a single-use field into a parameter turns an association into a dependency, which means less state, fewer lifecycle questions, and looser coupling. This is a common and healthy refactor.
- Tempting wrong turn
- Association is the before-state. The question is about after the refactor.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
13Car → Engine
A simulation models a car’s engine.
C++1 2 3 4 5 6
class Car { std::unique_ptr<Engine> engine_; public: Car() : engine_(std::make_unique<Engine>()) {} void start() { engine_->ignite(); } };
Show answer
Answer Composition
- Deciding clue
- std::
unique_ptrcreated in the constructor: exactly one owner, freed in Car’s destructor. - Why
unique_ptrencodes exclusive ownership in the type system. The car makes its own engine, nobody else can hold an owning pointer to it, and it’s destroyed when the car is. Even if the constructor took aunique_ptr<Engine>by move, it would still be composition, since ownership transfers and stays exclusive.- Tempting wrong turn
- Dependency is tempting because
start()merely calls the engine. The call is incidental; the storedunique_ptris what defines the relationship.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
14Order → Address
The order stores
address_idpointing at a row in the customer’s address book. If the customer edits that address, the order now shows the new address.Show answer
Answer Association
- Deciding clue
- The order references an independent, shared record by id.
- Why
- The address book entry exists independently and can be referenced by many orders, so this is a stored link, not ownership. (It’s also a real-world bug: past orders would silently change their delivery address.)
- Tempting wrong turn
- Aggregation is tempting because an order “has” an address. The address isn’t part of the order here; the order just points at it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
15Report → ObjectMapper
A report serializes itself to JSON.
Java1 2 3 4 5 6 7
class Report { private final List<Row> rows; String toJson() throws JsonProcessingException { return new ObjectMapper().writeValueAsString(rows); } }
Show answer
Answer Dependency
- Deciding clue
- The ObjectMapper is a temporary created inside one method and dropped when it returns.
- Why
- Creating an object isn’t owning it. Composition needs the created thing to be held as part of the whole’s state. Here nothing references the mapper after
toJson()returns. - Tempting wrong turn
- Composition is tempting because Report calls
new ObjectMapper()itself. “Created inside” is a composition tell only when the result is stored.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
16Server → Channel
Channels are created inside a Discord server, can’t be moved to another server, and are deleted when the server is deleted.
Show answer
Answer Composition
- Deciding clue
- Created inside, never moves, dies with the server.
- Why
- Whole–part, the part can’t outlive the whole, and it can’t be shared. All three composition signals are present.
- Tempting wrong turn
- Aggregation would apply only if channels could survive the server or be shared with another server.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
17Checkout → PricingStrategy
A checkout receives its pricing strategy through the constructor. The same HourlyPricing instance is shared by every checkout.
Python1 2 3 4 5 6
class Checkout: def __init__(self, pricing: PricingStrategy): self.pricing = pricing def total(self, cart: Cart) -> Money: return self.pricing.price(cart)
Show answer
Answer Association
Also accepted Aggregation. Also accepted. Drawing ◇ signals “pluggable, shareable component”, and many diagrams draw strategies that way. The stricter reading is that a pricing policy isn’t a structural part of a checkout.
- Deciding clue
- The strategy is now stored in a field, so the link outlives every method call.
- Why
- Compared with passing the strategy into each call, the link is now stored, which moves it from dependency to association. Is a pricing policy a part of a checkout? Usually no: it’s a collaborator the checkout holds, so plain association is the stricter answer.
- Tempting wrong turn
- Composition is tempting because it’s set in the constructor. The instance is shared across checkouts, which rules out exclusive ownership.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
18VendingMachine → Coin
insert(coin)checks the coin’s diameter and weight, adds its value to an integer balance, and drops the physical coin into the cash box. The VendingMachine object never stores Coin objects.Show answer
Answer Dependency
- Deciding clue
- Only the coin’s value survives as an int; the Coin object isn’t kept.
- Why
- The machine reads the coin during
insert()and then forgets it. Keeping a number derived from B isn’t keeping B. - Tempting wrong turn
- Aggregation is tempting because the physical machine “contains” coins in its cash box. Model the software objects, not the hardware.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
19TicTacToe → Board
A game class sets up its board.
TypeScript1 2 3 4 5 6 7 8
class TicTacToe { private readonly board = new Board(3); private current: Player; constructor(private readonly players: [Player, Player]) { this.current = players[0]; } }
Show answer
Answer Composition
- Deciding clue
- Created inside as a private readonly field, never exposed or accepted from outside.
- Why
- Each game builds its own board, no other game can share it, and it’s discarded with the game. Note the contrast with players, which are passed in and outlive the game.
- Tempting wrong turn
- Aggregation is tempting by analogy with the players. Look at who constructs each part: the game builds the board but receives the players.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
20Car → Engine
At an engine-refurbishing shop, engines are tracked by serial number, pulled out of scrapped cars, rebuilt, and installed in other cars.
Show answer
Answer Aggregation
- Deciding clue
- The engine is still a part of a car, but it outlives cars and moves between them.
- Why
- Whole–part remains true while it’s installed, but the part has its own identity and lifetime. Swap the domain and the answer flips from composition to aggregation.
- Tempting wrong turn
- Composition is the answer at an automaker (#34). What changed is only the lifecycle rule, and that’s the whole point.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
21Order → uuid module
An order generates its own id.
Python1 2 3 4 5 6
import uuid class Order: def __init__(self, items: list[Item]): self.id = str(uuid.uuid4()) self.items = items
Show answer
Answer Dependency
- Deciding clue
- Only the generated string is stored; nothing from the uuid module is kept.
- Why
- The order calls
uuid4()once and keeps the result as a plain str. The order never holds the generator itself, so after__init__returns there’s no link to it. - Tempting wrong turn
- Association is tempting because something from the call is stored. Storing a value produced by B is not storing B.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
22Pod → Container
Containers are declared in the pod spec, share the pod’s network namespace, start with the pod, and are gone when the pod is deleted. You can’t move a running container into another pod.
Show answer
Answer Composition
- Deciding clue
- Declared inside the pod, share its namespaces, and die with it.
- Why
- A container’s runtime existence is entirely inside one pod. Whole–part, no independent lifetime, no sharing.
- Tempting wrong turn
- Aggregation is tempting because the same image can run in many pods. The image is shared; the container instance is not. Don’t confuse a template with an instance.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
23Handler → Store
An HTTP handler gets its storage through a constructor.
Go1 2 3 4 5 6 7 8 9 10 11 12 13
type Handler struct { store Store log *slog.Logger } func NewHandler(store Store, log *slog.Logger) *Handler { return &Handler{store: store, log: log} } func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { v, err := h.store.Get(r.PathValue("key")) // ... }
Show answer
Answer Association
Also accepted Dependency. Accepted as a team convention: many diagrams draw injected service collaborators as dashed dependencies to keep only domain relationships solid. Say which convention you’re using.
- Deciding clue
- The store is kept in a field after the constructor returns.
- Why
- By the strict test the handler still holds the store after any method returns, so it’s an association. It isn’t whole–part: the store is shared infrastructure the handler talks to.
- Tempting wrong turn
- Dependency feels right because “it’s just a service”. The strict test doesn’t care whether B is data or behavior.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
24EntryGate → Vehicle
EntryGate.scan(vehicle)reads the plate, asks the lot for a free spot, prints a ticket and returns it. The gate keeps no record of vehicles.Show answer
Answer Dependency
- Deciding clue
- The vehicle is only a parameter of
scan(); the gate stores nothing about it. - Why
- The link lasts for a single call. Whatever needs to remember the vehicle (the ticket) is a different class with its own association.
- Tempting wrong turn
- Association is tempting because gates and vehicles interact constantly. Frequent interaction still isn’t stored state.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
25Node → Node (via next)
A singly linked list in Rust.
Rust1 2 3 4
struct Node { val: i32, next: Option<Box<Node>>, }
Show answer
Answer Composition
- Deciding clue
Box<T>means unique ownership: dropping a node drops its entire tail.- Why
- Rust makes the ownership explicit. Each node exclusively owns the next one, nothing else can own it, and it’s freed when its owner is. Compare with the Python version (#26), where the code can’t express ownership at all.
- Tempting wrong turn
- Association is the answer for the Python version (#26). Here the type system says “owns”, not “points to”.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
26Directory → File (inode)
On ext4, a file (an inode) can have several hard links in different directories. Removing one directory entry only frees the file when its link count reaches zero.
Show answer
Answer Aggregation
- Deciding clue
- The same file can be shared by several directories and outlives any one of them.
- Why
- A directory is a collection of entries (whole–part), but the underlying file is reference-counted and shared. Shared parts that survive the whole: aggregation.
- Tempting wrong turn
- Composition is the right answer for a simple LLD file system with no hard links. Real filesystems are more permissive than interview ones.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
27VehicleFactory → Car
A factory builds vehicles from a type string.
Python1 2 3 4 5 6 7
class VehicleFactory: def create(self, kind: str) -> Vehicle: if kind == "car": return Car() if kind == "truck": return Truck() raise ValueError(kind)
Show answer
Answer Dependency
- Deciding clue
- The factory creates a Car and immediately hands it to the caller; it keeps nothing.
- Why
- This is the UML «create» flavor of dependency: the factory knows how to construct Car, but the relationship ends at return. The caller owns the result.
- Tempting wrong turn
- Composition is tempting because the factory calls
Car(). Composition requires the creator to keep and own what it makes.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
28Order → ShippingAddress
At checkout, the order copies the customer’s shipping address into its own ShippingAddress value object. If the customer later edits their address book, existing orders don’t change.
Show answer
Answer Composition
- Deciding clue
- The order holds its own private snapshot that lives and dies with it.
- Why
- The snapshot belongs to exactly one order and is never shared. It disappears with the order. Snapshotting is also the correct business choice, because a shipped order must keep the address it shipped to.
- Tempting wrong turn
- Association is the answer when the order instead stores a reference to the address-book row (#33). A copy owned by the order is a part, not a link to the address book.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
29Worker → context.Context
A worker keeps the context it was created with.
Go1 2 3 4 5 6 7 8
type Worker struct { ctx context.Context jobs chan Job } func NewWorker(ctx context.Context) *Worker { return &Worker{ctx: ctx, jobs: make(chan Job)} }
Show answer
Answer Association
- Deciding clue
- The context is stored in a struct field, so it outlives the constructor call.
- Why
- Stored, not whole–part: association. This is exactly why the context package docs say not to store contexts in structs and to pass ctx as the first parameter instead. They want it to stay a request-scoped dependency, not become a long-lived association.
- Tempting wrong turn
- Dependency is what idiomatic Go would give you, but this code stores it, so the code is an association.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
30Tree → Blob
Git stores file contents as blobs addressed by their hash. Two commits whose trees contain an identical README point at the same blob. When a tree becomes unreachable and git gc collects it, any blob still reachable from other trees is kept.
Show answer
Answer Aggregation
- Deciding clue
- Content-addressed blobs are shared between trees and outlive any one tree.
- Why
- A tree is made up of its entries (whole–part), but deduplication means blobs are shared and independently live. Shared, surviving parts: aggregation.
- Tempting wrong turn
- Composition is tempting because a tree is “just” its files. Dedup is precisely what breaks exclusive ownership.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
31Account → InsufficientFundsException
A withdrawal can fail.
Java1 2 3 4 5 6 7 8 9
class Account { private long balance; void withdraw(long amount) { if (amount > balance) throw new InsufficientFundsException(balance, amount); balance -= amount; } }
Show answer
Answer Dependency
- Deciding clue
- The exception is created and thrown, never stored.
- Why
- Thrown exceptions are a classic dependency: Account’s code is coupled to the exception type, but no Account instance ever holds one.
- Tempting wrong turn
- Inheritance is tempting if you’re thinking about the exception’s own superclass. The question is about Account → exception, and Account doesn’t extend it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
32Car → Engine
In an automaker’s build system, an engine is assembled into exactly one car, isn’t tracked as a separate asset afterward, and is scrapped with the car.
Show answer
Answer Composition
- Deciding clue
- In this domain the engine has no life outside its car.
- Why
- Same two classes, but the domain rules decide: exclusive, not tracked separately, destroyed with the whole.
- Tempting wrong turn
- Aggregation is right for a different domain (see the refurbishing-shop version, #35). Never classify from the class names alone.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
33StockTicker → Subscriber
A ticker notifies subscribers without keeping them alive.
Python1 2 3 4 5 6 7 8 9 10 11 12
import weakref class StockTicker: def __init__(self): self._subs: "weakref.WeakSet[Subscriber]" = weakref.WeakSet() def subscribe(self, s: Subscriber): self._subs.add(s) def publish(self, price: float): for s in self._subs: s.on_price(price)
Show answer
Answer Association
- Deciding clue
- Subscribers are stored, but weakly: the ticker knows them without owning them.
- Why
- A weak reference is the code saying “I want to know you while you exist, but your lifetime isn’t my business.” Stored link, not whole–part: association. The subject–observer link in the Observer pattern is an association.
- Tempting wrong turn
- Aggregation is tempting because subscribers are kept in a collection. Being in a collection doesn’t make them parts of the ticker.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
34Image → Layer
Image layers are content-addressed. Ten images built FROM the same base share that base’s layers on disk, and docker rmi removes only the layers no other image uses.
Show answer
Answer Aggregation
- Deciding clue
- Layers are shared across images and survive the removal of any one image.
- Why
- An image is composed of a stack of layers (whole–part), but those layers are shared and reference-counted. Aggregation.
- Tempting wrong turn
- Composition would hold only if each image had private layers that vanished with it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
35Checkout → PricingStrategy
A checkout is handed a pricing strategy on each call.
Python1 2 3
class Checkout: def total(self, cart: Cart, pricing: PricingStrategy) -> Money: return pricing.price(cart)
Show answer
Answer Dependency
- Deciding clue
- The strategy is a parameter of one method and isn’t stored.
- Why
- Each call can pass a different strategy, and the checkout remembers none of them.
- Tempting wrong turn
- Realization is tempting because a strategy pattern is involved. Realization is the arrow from a concrete strategy to the interface, not from the user of the strategy.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
36Folder → File
In your interview design of an in-memory file system, every file has exactly one parent folder, and deleting a folder recursively deletes everything inside it.
Show answer
Answer Composition
- Deciding clue
- Exactly one parent, and recursive delete.
- Why
- These rules make the folder the sole owner: whole–part, no sharing, and the part can’t outlive the whole. This recursive composition is the Composite pattern (Folder contains Files and Folders).
- Tempting wrong turn
- Aggregation would apply if you added hard links or let files survive their folder.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
37_Node → _Node (via next)
A singly linked list in Python.
Python1 2 3 4 5 6 7 8 9 10 11 12
class _Node: __slots__ = ("value", "next") def __init__(self, value, next=None): self.value = value self.next = next class LinkedList: def __init__(self): self._head = None def push(self, value): self._head = _Node(value, self._head)
Show answer
Answer Association
Also accepted Composition. Defensible if you model each node as owning the rest of the chain (that’s literally what Rust’s Option<
Box<Node>> encodes). In a garbage-collected language, the list is the real owner.- Deciding clue
- The list owns the nodes; one node merely points at its neighbor.
- Why
- Ownership lives at the LinkedList level: the list creates nodes, keeps them private, and they die with it (LinkedList ◆ _Node). The next pointer is a stored navigation link between peers, a reflexive association.
- Tempting wrong turn
- Composition is tempting because the tail goes away with the node. What decides it here is that the design makes the list the owner and nothing states an exclusive-parent rule between nodes (compare #28, whose prompt states one).
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
38Student ↔ Course
A student takes many courses and a course has many students. For each pairing you must store the semester and the final grade.
Show answer
Answer Association class
- Deciding clue
- The link itself carries data (semester, grade).
- Why
- A grade belongs neither to the student alone nor to the course alone; it belongs to the pairing. When the link has attributes, promote it to a class: Enrollment.
- Tempting wrong turn
- Association is right only if you never need data about the pairing itself.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
39BinaryOp → Expr (left/right fields)
An AST node built by a parser. The parser builds every subtree fresh for exactly one parent and never reuses a node in two places; dropping a node drops its whole subtree.
Python1 2 3 4 5 6
class BinaryOp(Expr): def __init__(self, op: str, left: Expr, right: Expr): self.op, self.left, self.right = op, left, right # inside the parser: # node = BinaryOp("+", self.parse_term(), self.parse_term())
Show answer
Answer Composition
- Deciding clue
- Domain rule: each child belongs to exactly one parent and dies with it, even though it’s passed into the constructor.
- Why
- “Passed in vs. created inside” is a heuristic, not the definition. The lifecycle rules decide: exclusive parent, never shared, subtree dies with the node. That’s composition, and it’s the structure behind the Composite pattern.
- Tempting wrong turn
- Aggregation is tempting because the children arrive as constructor arguments. Don’t let a code tell override explicit lifecycle rules. (Also note BinaryOp → Expr via the base class is inheritance; this question is about the left/right fields.)
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
40Branch → Commit
A branch is a named pointer to one commit, its tip. Deleting the branch doesn’t delete the commit, and many branches can point at the same commit.
Show answer
Answer Association
- Deciding clue
- A branch points at a commit; it isn’t made of commits.
- Why
- Stored link: yes (the ref stores a commit hash). Whole–part: no. A branch is a movable label, not a container. So association.
- Tempting wrong turn
- Aggregation is tempting if you think of a branch as “containing its history”. Structurally it holds one pointer; history is reached by walking commit parents.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
41User ↔ Group
What does this table represent?
SQL1 2 3 4 5 6 7
CREATE TABLE memberships ( user_id BIGINT REFERENCES users(id), group_id BIGINT REFERENCES groups(id), role TEXT NOT NULL, -- 'owner' | 'admin' | 'member' joined_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (user_id, group_id) );
Show answer
Answer Association class
- Deciding clue
- A join table with its own columns (role,
joined_at). - Why
- A many-to-many join table that carries data is the relational form of an association class: Membership.
- Tempting wrong turn
- Association is right for a bare join table with only the two foreign keys.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
42A → B
A class diagram shows a solid line from A to B with a filled diamond at A’s end.
Show answer
Answer Composition
- Deciding clue
- Filled diamond on the whole’s end.
- Why
- ◆ means composition: A is the whole, B is a part that lives and dies with it. The diamond always sits on the whole side.
- Tempting wrong turn
- Aggregation uses the same shape but hollow (◇).
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
43Node → Node (via parent field)
A tree node keeps a pointer back to its parent.
Python1 2 3 4 5 6 7 8 9
class Node: def __init__(self, parent: "Node | None" = None): self.parent = parent self.children: list[Node] = [] def add_child(self) -> "Node": child = Node(parent=self) self.children.append(child) return child
Show answer
Answer Association
- Deciding clue
- It’s a back-reference: a child knows its parent but certainly doesn’t own it.
- Why
- The children list is composition (the node creates its children and they die with it). The parent field points the other way, and back-pointers never own. A stored link that isn’t whole–part from the child’s side is an association.
- Tempting wrong turn
- Composition is tempting because you see composition in the same class. One class can take part in several relationships; judge each field separately.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
44Cart ↔ Product
A cart can hold many products, a product can be in many carts, and for each pairing you must store the quantity and the price at the moment it was added.
Show answer
Answer Association class
- Deciding clue
- Quantity and captured price describe the pairing, not either side.
- Why
- This is CartItem. The cart then composes its CartItems, and each CartItem associates with one Product.
- Tempting wrong turn
- Aggregation is tempting because a cart “holds” products. A product can’t hold “quantity 3 in Tisan’s cart”; something in between has to.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
45Library → Book
What does this schema encode?
SQL1 2 3 4 5 6
CREATE TABLE books ( id BIGINT PRIMARY KEY, title TEXT NOT NULL, library_id BIGINT NULL REFERENCES libraries(id) ON DELETE SET NULL );
Show answer
Answer Aggregation
Also accepted Association. Accepted: the schema alone only proves an optional, non-cascading link (a
manager_idcolumn looks identical). The whole–part reading comes from the domain: a library is its collection.- Deciding clue
- A nullable FK with ON DELETE SET NULL: the book survives its library.
- Why
- The book belongs to a library (whole–part in this domain) but keeps living, unshelved, when the library is deleted. SET NULL is consistent with aggregation; CASCADE would signal composition.
- Tempting wrong turn
- Composition would use NOT NULL with CASCADE.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
46A → B
A class diagram shows a dashed line from A to B with an open arrowhead at B.
Show answer
Answer Dependency
- Deciding clue
- Dashed line, open arrowhead.
- Why
- The dashed open arrow means A uses B somewhere in its code (a parameter, local variable, return type or static call) but doesn’t keep it.
- Tempting wrong turn
- Realization is also dashed, but ends in a hollow triangle rather than an open arrowhead.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
47Show ↔ Seat
A seat (say, A9) physically belongs to a screen. Each show on that screen needs its own status for every seat (available, locked, booked) and its own price.
Show answer
Answer Association class
- Deciding clue
- Status and price belong to (show, seat), not to the seat or the show alone.
- Why
- This is ShowSeat: per-show inventory. Screen ◆ Seat is composition (the physical seat), while ShowSeat is the association class between Show and Seat, and it’s what gets locked during booking.
- Tempting wrong turn
- Composition is right for Screen → Seat, which is a different pair.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
48Book ↔ Author
What does this table represent?
SQL1 2 3 4 5
CREATE TABLE book_authors ( book_id BIGINT REFERENCES books(id), author_id BIGINT REFERENCES authors(id), PRIMARY KEY (book_id, author_id) );
Show answer
Answer Association
- Deciding clue
- A bare join table: two foreign keys and nothing else.
- Why
- Many-to-many with no data on the link is a plain association. Neither side is part of the other, and deleting a book doesn’t delete its authors.
- Tempting wrong turn
- Association class needs attributes on the link (author order or a royalty split, say). There are none here.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
49A → B
A class diagram shows a solid line from A to B with a hollow diamond at A’s end.
Show answer
Answer Aggregation
- Deciding clue
- Hollow diamond on the whole’s end.
- Why
- ◇ means aggregation: A is a whole made of B parts, but B can outlive A or be shared.
- Tempting wrong turn
- Composition is the filled version. A quick way to remember: filled means fully owned.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
50Admin → User
What object-model relationship does this table implement?
SQL1 2 3 4 5 6
CREATE TABLE users ( id BIGINT PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('member', 'admin')), email TEXT NOT NULL, admin_level INT NULL -- only set when type = 'admin' );
Show answer
Answer Inheritance
- Deciding clue
- A type discriminator plus subtype-only columns in one table.
- Why
- This is single-table inheritance: Admin is a kind of User with extra fields. ORMs map exactly this shape to a subclass.
- Tempting wrong turn
- Composition or association would need a second table or a foreign key; there’s no relationship between two rows here, only between two types.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
51A → B
A class diagram shows a dashed line from A to B ending in a hollow triangle at B.
Show answer
Answer Realization
- Deciding clue
- Dashed line, hollow triangle: A implements B’s contract.
- Why
- Realization: B is an interface (or protocol or trait) and A promises its behavior without inheriting an implementation.
- Tempting wrong turn
- Inheritance uses a solid line. Dashed means a contract, solid means a real superclass.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
52Order → Customer
What does this schema encode?
SQL1 2 3 4 5 6
CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, placed_at TIMESTAMPTZ NOT NULL );
Show answer
Answer Association
- Deciding clue
- Every order must reference a customer, but neither is part of the other.
- Why
- NOT NULL says the link is mandatory; RESTRICT says you can’t delete a customer who has orders. A customer isn’t a part of an order. Orders are also kept as records rather than dying with the customer. Mandatory doesn’t mean owned.
- Tempting wrong turn
- Composition is tempting because of NOT NULL. Composition is about cascading lifetime, and RESTRICT explicitly refuses to cascade.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
53A → B
A class diagram shows a solid line from A to B ending in a hollow triangle at B.
Show answer
Answer Inheritance
- Deciding clue
- Solid line, hollow triangle pointing at the parent.
- Why
- This is generalization: A is a kind of B, and the triangle points at the superclass.
- Tempting wrong turn
- Realization is the dashed version of the same arrow.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
54Post → Comment
What does this schema encode between posts and comments?
SQL1 2 3 4 5 6 7
CREATE TABLE comments ( id BIGINT PRIMARY KEY, post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id BIGINT NOT NULL REFERENCES users(id), body TEXT NOT NULL );
Show answer
Answer Composition
- Deciding clue
post_idis NOT NULL with ON DELETE CASCADE.- Why
- Every comment belongs to exactly one post and dies with it: whole–part with no independent lifetime.
- Tempting wrong turn
- Aggregation would use a nullable
post_idwith SET NULL.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
55A ↔ B, with C attached
A solid line connects A and B. A dashed line runs from a third class box, C, to the middle of that line.
Show answer
Answer Association class
- Deciding clue
- A class hanging off the middle of an association line.
- Why
- This is UML’s association-class notation: C holds the data that belongs to the A–B link (for example, Enrollment between Student and Course).
- Tempting wrong turn
- Dependency is tempting because of the dashed line. Here the dashed line attaches to a relationship, not to a class.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
56Comment → User (author)
What is the relationship between a comment and its author in this schema?
SQL1 2 3 4 5 6 7
CREATE TABLE comments ( id BIGINT PRIMARY KEY, post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id BIGINT NOT NULL REFERENCES users(id), body TEXT NOT NULL );
Show answer
Answer Association
- Deciding clue
author_idis a mandatory reference with no cascade, and a user is not a part of a comment.- Why
- The comment knows its author persistently, but users exist independently and write many comments. One table can encode composition in one column and association in another.
- Tempting wrong turn
- Composition is tempting because the
post_idcolumn in this same table does encode composition (#49). Judge each column separately.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
57Calculator → Number
A diagram shows Calculator ◆—— Number, drawn because
add(a: Number, b: Number)takes Numbers. Calculator has no Number fields. What should it be?Show answer
Answer Dependency
- Deciding clue
- Number appears only in a method signature.
- Why
- Parameters are dependencies. Nothing is stored, so there’s no association at all, let alone composition.
- Tempting wrong turn
- Composition is the error being tested: a method signature never implies ownership.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
58Stack → ArrayList
What relationship does this code actually create?
Java1 2 3 4
class Stack<T> extends ArrayList<T> { void push(T x) { add(x); } T pop() { return remove(size() - 1); } }
Show answer
Answer Inheritance
- Deciding clue
- extends ArrayList.
- Why
- The code says “a Stack is an ArrayList”. It’s a classic design smell: Stack now exposes
add(i, x),get(i)andremove(i), which break stack semantics. The better design is composition, with a private list field (Java’s own Stack extends Vector is the historical example of this mistake). - Tempting wrong turn
- Composition is what it should be, not what it is. Classify the code you’re given, then critique it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
59A ↔ B
A plain solid line connects A and B, labelled 1 near A and 0..* near B. There are no diamonds, triangles or arrowheads.
Show answer
Answer Association
- Deciding clue
- A plain solid line with multiplicities.
- Why
- A plain solid line is an association. Multiplicities only make sense for stored links, which is another way to tell it apart from a dependency.
- Tempting wrong turn
- Aggregation needs a diamond. Multiplicity alone doesn’t imply whole–part.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
60Team → Employee
A diagram shows Team ◆—— Employee (composition). In the system, when a team is dissolved its people are reassigned to other teams. What should it be?
Show answer
Answer Aggregation
- Deciding clue
- The parts survive the whole being dissolved.
- Why
- Still whole–part (a team is its members), but employees outlive the team, so the diamond should be hollow.
- Tempting wrong turn
- Association is defensible-sounding, but a team really is defined by its members, which is the whole–part signal.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
61Circle → Shape
A shape hierarchy with an abstract base class.
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
from abc import ABC, abstractmethod import math class Shape(ABC): @abstractmethod def area(self) -> float: ... def describe(self) -> str: return f"{type(self).__name__}, area {self.area():.2f}" class Circle(Shape): def __init__(self, r: float): self.r = r def area(self) -> float: return math.pi * self.r ** 2
Show answer
Answer Inheritance
- Deciding clue
- Circle subclasses an abstract class and inherits the concrete
describe()method. - Why
- Subclassing an abstract class is generalization (inheritance), not realization, because Circle inherits real behavior,
describe(). Realization is for pure contracts with nothing to inherit. - Tempting wrong turn
- Realization is tempting because of ABC and @abstractmethod. The presence of an inherited implementation settles it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
62Doctor → Patient
A teammate drew Doctor ◇—— Patient (aggregation), reasoning that “a doctor has patients”. Each doctor stores a list of their current patients; patients exist independently and can see several doctors. What should it be?
Show answer
Answer Association
- Deciding clue
- “Has” in English isn’t whole–part. A doctor isn’t made of patients.
- Why
- The doctor stores the link, so it’s at least an association. But a patient isn’t structurally a part of a doctor, so it’s not aggregation. Don’t let everyday words choose your diamond.
- Tempting wrong turn
- Aggregation is the teammate’s mistake: reading possession-language as whole–part.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
63Circle → Drawable
Note that Circle has no base class.
Python1 2 3 4 5 6 7 8 9 10 11 12 13
from typing import Protocol class Drawable(Protocol): def draw(self, canvas: "Canvas") -> None: ... class Circle: def __init__(self, x: float, y: float, r: float): self.x, self.y, self.r = x, y, r def draw(self, canvas: "Canvas") -> None: canvas.ellipse(self.x, self.y, self.r, self.r) def render(items: list[Drawable], canvas: "Canvas"): ...
Show answer
Answer Realization
- Deciding clue
- Circle satisfies the Drawable protocol structurally, with no inheritance.
- Why
- A Protocol is a pure contract, and any class with a matching
draw()conforms. Conforming to a contract without inheriting anything is realization, even when it’s implicit. - Tempting wrong turn
- “No relationship” or dependency is tempting because Circle never mentions Drawable. Implicit conformance is still realization (Go interfaces work the same way).
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
64Invoice → LineItem
A diagram shows Invoice ◇—— LineItem (aggregation). Line items are created by the invoice, numbered within it, and deleted with it. What should it be?
Show answer
Answer Composition
- Deciding clue
- Created by the whole, identified within it, and deleted with it.
- Why
- Every lifecycle signal says exclusive ownership. The diamond should be filled.
- Tempting wrong turn
- Aggregation is the mistake: a line item can’t exist without its invoice.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
65StripeGateway → Card
What is the relationship between the gateway and the card it charges?
Java1 2 3 4 5 6 7 8 9 10 11
interface PaymentGateway { Receipt charge(Money amount, Card card); } class StripeGateway implements PaymentGateway { public Receipt charge(Money amount, Card card) { String token = card.tokenize(); // call Stripe with token... return new Receipt(/* ... */); } }
Show answer
Answer Dependency
- Deciding clue
- Card appears only as a parameter of
charge(). - Why
- The gateway reads the card for one call and keeps nothing. (StripeGateway → Receipt is also a dependency, because it’s created and returned.)
- Tempting wrong turn
- Association is tempting because payment cards are important domain objects. Importance doesn’t make a link persistent.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
66ConsoleLogger → Logger
A diagram draws ConsoleLogger ——▷ Logger with a solid line and hollow triangle. Logger is a Java interface with no fields and no method bodies. What should it be?
Show answer
Answer Realization
- Deciding clue
- The target is an interface, so the arrow should be dashed.
- Why
- Implementing an interface is realization (dashed line, hollow triangle). A solid line claims ConsoleLogger inherits an implementation, and there is none to inherit.
- Tempting wrong turn
- Inheritance is what the diagram currently shows.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
67Parser → input &str
A zero-copy parser.
Rust1 2 3 4 5 6 7 8
struct Parser<'a> { input: &'a str, pos: usize, } impl<'a> Parser<'a> { fn new(input: &'a str) -> Self { Parser { input, pos: 0 } } }
Show answer
Answer Association
- Deciding clue
- The parser stores a borrowed reference for its whole life but doesn’t own the text.
- Why
- Stored, so not a dependency. Borrowed, so not owned. The lifetime ‘a even forces the parser to die before the input does, which is the opposite of composition, where the part dies with the whole.
- Tempting wrong turn
- Composition is tempting because it’s a struct field. A field holding a borrow is a link, not ownership; Rust spells out the difference.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
68ParkingLot → Vehicle
A diagram shows ParkingLot ◆—— Vehicle, “since the lot contains the vehicles parked in it”. In the code, ParkingLot only receives vehicles as an argument to
park()andunpark(); occupancy is recorded inParkingSpot.current_vehicle. What should ParkingLot → Vehicle be?Show answer
Answer Dependency
- Deciding clue
- The lot only sees vehicles as method parameters; ParkingSpot is what stores them.
- Why
- Vehicles exist before and after parking, so composition is wrong on lifecycle alone. In this design the lot doesn’t even store them, which makes it a dependency. The association lives on ParkingSpot → Vehicle.
- Tempting wrong turn
- Association is right for ParkingSpot → Vehicle, not for the lot.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
69FileStore → Store
A key-value store abstraction in Go.
Go1 2 3 4 5 6 7 8 9
type Store interface { Get(key string) ([]byte, error) Put(key string, val []byte) error } type FileStore struct{ dir string } func (f *FileStore) Get(key string) ([]byte, error) { /* ... */ } func (f *FileStore) Put(key string, val []byte) error { /* ... */ }
Show answer
Answer Realization
- Deciding clue
*FileStorehas Store’s full method set, so it implements Store implicitly.- Why
- Go has no implements keyword, but satisfying an interface’s method set is exactly realization. Diagram it as FileStore - - -▷ Store. (Strictly it is
*FileStorethat satisfies Store, because the methods have pointer receivers; a FileStore value isn’t assignable to Store.) - Tempting wrong turn
- Inheritance is impossible here: Go has no class inheritance.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
70Elevator → Floor
Each Elevator keeps a reference to the Floor object it’s currently at, updated as it moves. Floors belong to the Building.
Show answer
Answer Association
- Deciding clue
- A stored, changing reference to an object owned by someone else.
- Why
- The elevator remembers its current floor (stored), but a floor isn’t a part of an elevator (Building ◆ Floor is the ownership). A stored link that isn’t whole–part is an association.
- Tempting wrong turn
- Aggregation is tempting because the reference persists. Persistence gets you to association; whole–part is a separate question.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
71Renderer → Config
A renderer reads its configuration per call.
Rust1 2 3 4 5 6 7
struct Renderer { width: u32, height: u32 } impl Renderer { fn render(&self, scene: &Scene, cfg: &Config) -> Image { // ... } }
Show answer
Answer Dependency
- Deciding clue
- cfg is borrowed as a parameter, and the borrow ends when render returns.
- Why
- The struct has no Config field. Rust makes it extra clear: the borrow can’t outlive the call.
- Tempting wrong turn
- Association is tempting because every render needs a config. It’s needed every time but stored never.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
72Building → Floor
Floors are created when the Building is modeled. “Floor 3” has no meaning on its own, and removing the building from the system removes its floors.
Show answer
Answer Composition
- Deciding clue
- Floors are created with the building and have no meaning or life outside it.
- Why
- Whole–part, created by the whole, and dies with it. This is the composition the elevator’s association points into.
- Tempting wrong turn
- Aggregation would need floors to survive or be shared, and neither is possible.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
73StripeGateway → PaymentGateway
A payment gateway abstraction.
Java1 2 3 4 5 6 7 8 9 10 11
interface PaymentGateway { Receipt charge(Money amount, Card card); } class StripeGateway implements PaymentGateway { public Receipt charge(Money amount, Card card) { String token = card.tokenize(); // call Stripe with token... return new Receipt(/* ... */); } }
Show answer
Answer Realization
- Deciding clue
- implements PaymentGateway.
- Why
- StripeGateway fulfils an interface’s contract: realization, drawn with a dashed line and hollow triangle.
- Tempting wrong turn
- Inheritance would be extends with a concrete superclass.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
74Dispatcher → HallCall
The Dispatcher receives a
HallCall(floor=7, direction=UP), picks the best elevator, appends 7 to that elevator’s stop queue (a list of ints), and discards the HallCall object.Show answer
Answer Dependency
- Deciding clue
- The call object is read and discarded; only the integer 7 survives, inside a different object.
- Why
- Within
dispatch()the dispatcher uses the HallCall, then holds nothing. Extracting a value and storing it elsewhere doesn’t make the original object stored. - Tempting wrong turn
- Aggregation is tempting because pending calls feel like a queue the dispatcher owns. The dispatcher doesn’t keep the call objects in this design.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
75Fleet → Truck
Trucks are transferred between regional fleets.
C++1 2 3 4 5 6 7
class Fleet { std::vector<std::shared_ptr<Truck>> trucks_; public: void add(std::shared_ptr<Truck> t) { trucks_.push_back(std::move(t)); } };
Show answer
Answer Aggregation
- Deciding clue
shared_ptrand a passed-in part: shared ownership, so the truck can outlive this fleet.- Why
- A fleet is made up of trucks (whole–part), and
shared_ptrsays no single fleet decides a truck’s lifetime. That’s aggregation. - Tempting wrong turn
- Composition would use
unique_ptror value members created inside.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
76Piece → Square
Each Piece has a position field pointing at the Square it currently occupies, which changes on every move. Squares are owned by the Board.
Show answer
Answer Association
- Deciding clue
- A stored link to an object owned by the board.
- Why
- The piece remembers where it is, but a square isn’t part of a piece. Board ◆ Square is the ownership; Piece → Square is a navigational association that changes over time.
- Tempting wrong turn
- Composition is tempting if you mix it up with Board → Square.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
77Money → Display
Formatting money for output.
Rust1 2 3 4 5 6 7 8 9
use std::fmt; struct Money { cents: i64, currency: Currency } impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} {:.2}", self.currency, self.cents as f64 / 100.0) } }
Show answer
Answer Realization
- Deciding clue
- impl Display for Money: implementing a trait.
- Why
- A trait is a contract, and implementing it is realization. Rust has no struct inheritance at all.
- Tempting wrong turn
- Dependency is tempting because Display comes from the standard library. Implementing a contract is a stronger statement than merely using a type.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, implements an interface
- Realization
-
78Email → Attachment
An attachment is encoded inside one email’s MIME body. Forwarding the email creates a new copy of the attachment, and deleting the email deletes it.
Show answer
Answer Composition
- Deciding clue
- Each email has its own copy, and it dies with the email.
- Why
- Forwarding copies rather than shares, so ownership stays exclusive. Whole–part with no independent lifetime: composition.
- Tempting wrong turn
- Aggregation would apply if the attachment were stored once in a shared blob store and referenced by many emails (as some mail servers dedupe). The rules given here say copy, not share.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
79Employee → Department
Departments are created before employees and outlive them.
C++1 2 3 4 5 6
class Employee { Department* dept_; // non-owning public: explicit Employee(Department* d) : dept_(d) {} const Department& department() const { return *dept_; } };
Show answer
Answer Association
- Deciding clue
- A raw, explicitly non-owning pointer to something that isn’t a part of the employee.
- Why
- Stored link, so association. The employee isn’t the whole and the department isn’t its part (if anything, it’s the other way round), so there’s no diamond on this end.
- Tempting wrong turn
- Aggregation is tempting if you read the pair backwards (Department ◇ Employee). Check the direction you’re asked about.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
80Album → Photo
Every photo lives in your library. Albums group photos; one photo can be in several albums, and deleting an album leaves its photos in the library.
Show answer
Answer Aggregation
Also accepted Association. Accepted: albums sit on the blurry aggregation/association line. Aggregation is preferred because an album is defined by the photos in it.
- Deciding clue
- Albums are collections of photos that live elsewhere and can be shared.
- Why
- An album is made of photos (whole–part), but those photos outlive it and appear in other albums. Aggregation.
- Tempting wrong turn
- Composition would hold if deleting an album deleted its photos, which some older apps actually did.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
81SignupValidator → ValidationResult
A validator returns a fresh result object on every call.
TypeScript1 2 3 4 5 6 7 8
class SignupValidator { validate(form: SignupForm): ValidationResult { const errors: string[] = []; if (!form.email.includes("@")) errors.push("email"); if (form.password.length < 12) errors.push("password"); return new ValidationResult(errors); } }
Show answer
Answer Dependency
- Deciding clue
- The result is created and returned; the validator keeps nothing.
- Why
- A return type that isn’t stored is a dependency (and so is the SignupForm parameter). The validator is stateless, so it can’t hold an association to anything.
- Tempting wrong turn
- Composition is tempting because the validator creates the result. Created and handed away is «create» dependency, not ownership.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
82Event → Reminder
Reminders are configured per event (“10 minutes before”). Deleting the event deletes its reminders, and a reminder can’t be attached to a different event.
Show answer
Answer Composition
- Deciding clue
- Configured per event, can’t move, and dies with the event.
- Why
- A reminder only makes sense relative to its event. Whole–part, exclusive, same lifetime.
- Tempting wrong turn
- Association is tempting because a reminder could be seen as a separate notification object. Its existence is fully tied to one event.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
83Dashboard → Widget
Widgets are reusable across dashboards.
TypeScript1 2 3 4 5 6 7 8
class Dashboard { constructor(public title: string, private widgets: Widget[]) {} render() { return this.widgets.map(w => w.render()); } } const cpu = new CpuWidget(); const ops = new Dashboard("Ops", [cpu, new LatencyWidget()]); const exec = new Dashboard("Exec", [cpu]); // same instance
Show answer
Answer Aggregation
- Deciding clue
- The same widget instance sits in two dashboards.
- Why
- A dashboard is composed of widgets (whole–part), but widgets are created outside and shared. Sharing rules out composition, leaving aggregation.
- Tempting wrong turn
- Composition is tempting because a dashboard is its widgets visually. The shared cpu instance disproves exclusive ownership.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
84User → User (follows)
Users follow other users. A follow stores nothing except who follows whom.
Show answer
Answer Association
- Deciding clue
- A stored, directed link between peers of the same class, carrying no data.
- Why
- Reflexive, directed association. If you later add
followed_ator notification settings, it becomes an association class (Follow). - Tempting wrong turn
- Association class needs data on the link, and this follow has none.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
85HealthHandler → http.Request
A health-check endpoint.
Go1 2 3 4 5
type HealthHandler struct{ started time.Time } func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "up %s", time.Since(h.started)) }
Show answer
Answer Dependency
- Deciding clue
- The request exists only as a parameter of ServeHTTP.
- Why
- Each request is handled and forgotten; the handler has no request field. Request-scoped objects are almost always dependencies of the handler.
- Tempting wrong turn
- Association is tempting because a handler’s entire job is requests. Its job is to process them, not to remember them.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
86Fleet → Aircraft
An airline’s Fleet object lists the aircraft it operates. Aircraft are leased: when a lease ends, the same aircraft, with the same registration, joins another airline’s fleet.
Show answer
Answer Aggregation
- Deciding clue
- An aircraft is part of a fleet for a while, then moves to another.
- Why
- A fleet is made up of aircraft (whole–part), but the aircraft has its own identity and outlives the relationship. Aggregation.
- Tempting wrong turn
- Association is tempting because the aircraft is an independent asset (compare Employee → Laptop). The difference is the whole: a fleet is made of aircraft, while an employee is not made of laptops.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? Yes
- Aggregation
-
87Person → Address
Address is an immutable value. Changing your address means replacing it with a new Address, never sharing one instance between people.
Python1 2 3 4 5 6 7 8 9 10 11 12
from dataclasses import dataclass, field @dataclass(frozen=True) class Address: line1: str city: str pin: str @dataclass class Person: name: str home: Address = field(default_factory=lambda: Address("", "", ""))
Show answer
Answer Composition
- Deciding clue
- An immutable value object owned by exactly one person.
- Why
- Value objects are the cleanest composition: no identity of their own, never shared by policy, and gone when their owner is gone.
- Tempting wrong turn
- Association is tempting because Address looks like a standalone class. A value object with no identity can’t be the far end of a link between peers.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
88User ↔ Company
A user can work at several companies over a career, and a company has many employees. For each pairing you store a title, a start date and an optional end date.
Show answer
Answer Association class
- Deciding clue
- Title and dates describe the employment, not the user or the company.
- Why
- The data belongs to the pairing, so promote it: Position (or Employment). If the same user can hold two positions at one company over time, each link needs its own identity: in strict UML, mark the association ends {nonunique} or model Position as a full class with two associations.
- Tempting wrong turn
- Association loses the title and dates.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
89PaymentService → Logger
A module-level logger, used inside a class.
Python1 2 3 4 5 6 7 8
import logging log = logging.getLogger(__name__) class PaymentService: def refund(self, payment_id: str) -> None: log.info("refunding %s", payment_id) ...
Show answer
Answer Dependency
Also accepted Association. Some designers treat a module-level singleton the class always uses as an association. By the strict test it’s a dependency: no PaymentService instance holds the logger.
- Deciding clue
- The logger is a module global; no PaymentService instance stores it.
- Why
- The class’s code reaches for a global during
refund(). No instance keeps a reference, so the strict test says dependency, the same as a static call. - Tempting wrong turn
- Association is tempting because the logger is used everywhere and lives forever. Long-lived B doesn’t create a link unless A holds it.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
90Employee → Laptop
IT assigns each laptop to one employee at a time. When someone leaves, their laptop is wiped and reassigned. Laptops are tracked by serial number.
Show answer
Answer Association
- Deciding clue
- An assignment link to an independently tracked asset that isn’t a part of the employee.
- Why
- The link is stored, so it’s not a dependency. An employee isn’t made up of laptops, so it’s not whole–part. If you later need
assigned_onandreturned_ondates, it becomes an association class (Assignment). - Tempting wrong turn
- Aggregation is tempting because the employee “has” a laptop. Possession isn’t composition of parts.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
91LinkedList → Node
A linked list with a private nested node class.
Java1 2 3 4 5 6 7 8 9 10 11 12
public class LinkedList<T> { private Node<T> head; private int size; private static class Node<T> { T value; Node<T> next; Node(T v, Node<T> n) { value = v; next = n; } } public void addFirst(T v) { head = new Node<>(v, head); size++; } }
Show answer
Answer Composition
- Deciding clue
- Node is private, created only by the list, and invisible outside it.
- Why
- The list is the sole creator and owner of its nodes. They’re an implementation detail that dies with the list. (Node → Node via next is a separate association between peers.)
- Tempting wrong turn
- Association is right for the next pointer, not for the list-to-node relationship.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
92Playlist ↔ Song
The product team now wants every playlist row to show who added the song and when, and to keep a manual position. The same song can appear twice in one playlist.
Show answer
Answer Association class
- Deciding clue
- Added-by, added-at and position belong to each entry, and duplicates need separate identities.
- Why
- A plain playlist-to-song link can’t hold “added by Asha on Monday at position 4”, and it can’t tell two copies of the same song apart. Each entry becomes an object: PlaylistEntry, an association class (the playlist composes its entries). Strictly, allowing the same song twice needs {nonunique} ends or PlaylistEntry as a full class, because a default association class allows one link per pair.
- Tempting wrong turn
- Aggregation was the answer before the link gained data. Requirements changed, so the model must change.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
93Leaderboard → Comparator
A leaderboard sorts on demand.
Java1 2 3 4 5 6 7 8
class Leaderboard { private final List<Player> players = new ArrayList<>(); List<Player> top(int n) { players.sort(Comparator.comparingInt(Player::score).reversed()); return players.subList(0, Math.min(n, players.size())); } }
Show answer
Answer Dependency
- Deciding clue
- The comparator is built and used inside
top()and never stored. - Why
- A local, short-lived object used during one method: dependency. Compare with Leaderboard → Player, which is stored in a field.
- Tempting wrong turn
- Composition is tempting because the leaderboard creates the comparator. It isn’t held as state.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? No
- Dependency
-
94Recipe ↔ Ingredient
Ingredients (flour, butter) are a shared catalog. Each recipe uses many ingredients, and for each one you store a quantity and a unit.
Show answer
Answer Association class
- Deciding clue
- “250 g” belongs to (this recipe, flour), not to flour itself.
- Why
- Quantity and unit describe the pairing, so promote the link to RecipeIngredient.
- Tempting wrong turn
- Composition is tempting because recipes “contain” ingredients. But flour is a shared catalog item, not owned by any recipe.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
95Logger → Appender
Appenders are configured once and attached to several loggers.
Java1 2 3 4 5 6 7
Appender file = new FileAppender("app.log"); Logger http = Logger.get("http"); Logger db = Logger.get("db"); http.addAppender(file); db.addAppender(file); // the same appender, shared
Show answer
Answer Association
Also accepted Aggregation. Accepted if you read a logger’s configured appender set as its parts. The stricter reading treats appenders like subscribers: outputs the logger sends to, not pieces of it.
- Deciding clue
- The logger stores appenders across calls, and one appender instance is shared by two loggers.
- Why
- Stored, so not a dependency. Shared, so not composition. Is an appender a structural part of a logger? It’s closer to a subscriber: an output the logger sends events to. That makes association the strict answer, consistent with Checkout → PricingStrategy (#21) and StockTicker → Subscriber (#25).
- Tempting wrong turn
- Dependency is tempting because the logger “just writes to” an appender. It keeps the appender across calls, so the link is stored.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? No
- Association
-
96Employee ↔ Project
Employees are staffed on several projects at once. Each staffing has a role (lead, reviewer) and a percentage allocation.
Show answer
Answer Association class
- Deciding clue
- Role and allocation exist only for a specific employee on a specific project.
- Why
- This is Assignment, an association class carrying data that neither endpoint can hold alone.
- Tempting wrong turn
- Aggregation is tempting because a project “has” staff. The data on each staffing is what forces a class.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? Yes
- Association class
-
97BillingService → Logger
Go struct embedding.
Go1 2 3 4 5 6 7 8 9 10
type Logger struct{ prefix string } func (l Logger) Info(msg string) { fmt.Println(l.prefix, msg) } type BillingService struct { Logger // embedded by value rates map[string]float64 } // svc.Info("charged") works through method promotion
Show answer
Answer Composition
- Deciding clue
- Embedding by value places the Logger inside BillingService’s memory; promotion is only syntax.
- Why
- An embedded value field is still a field: created with the struct, destroyed with it, never shared. Method promotion lets you call
svc.Info(), but a BillingService still can’t be passed where a Logger is expected, so there’s no subtyping. - Tempting wrong turn
- Inheritance is the classic misreading of embedding. Go has no inheritance; embedding is composition plus delegation sugar.
The path through the tree
- Is A a kind of B, or does A implement B's contract? No
- Is the link stored, so it still exists after the method returns? Yes
- Does the link itself carry data (quantity, status, dates, role)? No
- Is it whole–part: is A made of B? Yes
- Can the part outlive the whole, or be shared by another whole? No
- Composition
-
98InsufficientFunds → PaymentError
An exception hierarchy.
Python1 2 3 4 5 6 7 8 9 10 11 12
class PaymentError(Exception): def __init__(self, payment_id: str, msg: str): super().__init__(msg) self.payment_id = payment_id class InsufficientFunds(PaymentError): pass try: charge(card, amount) except PaymentError as e: # also catches InsufficientFunds retry_later(e.payment_id)
Show answer
Answer Inheritance
- Deciding clue
- InsufficientFunds subclasses PaymentError and inherits its fields and behavior.
- Why
- It’s a kind of PaymentError, and code that catches the parent catches the child. That substitutability is what inheritance is for.
- Tempting wrong turn
- Dependency is the relationship between
charge()’s caller and these exceptions, not between the two exception classes.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
99AdminUser → User
Admins can edit any document.
TypeScript1 2 3 4 5 6 7 8
class User { constructor(public id: string, public email: string) {} canEdit(doc: Doc): boolean { return doc.ownerId === this.id; } } class AdminUser extends User { override canEdit(_doc: Doc): boolean { return true; } }
Show answer
Answer Inheritance
- Deciding clue
- extends User and overrides a method.
- Why
- An admin is a kind of user that behaves differently in one place. A subclass with an override is textbook generalization.
- Tempting wrong turn
- Dependency applies to AdminUser → Doc, which is a parameter. Make sure you answer for the pair asked.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
-
100Truck → Vehicle
Vehicle types in a parking-lot design.
Python1 2 3 4 5 6 7 8 9 10
class Vehicle: SIZE = Size.MEDIUM def __init__(self, plate: str): self.plate = plate class Truck(Vehicle): SIZE = Size.LARGE class Motorcycle(Vehicle): SIZE = Size.SMALL
Show answer
Answer Inheritance
- Deciding clue
Truck(Vehicle)subclasses Vehicle.- Why
- The code expresses “a Truck is a Vehicle”. Interview follow-up: when subclasses differ only in a constant, a VehicleType enum field is often simpler. Reach for subclasses when behavior differs.
- Tempting wrong turn
- Composition (a type field) is the alternative design, not what this code does.
The path through the tree
- Is A a kind of B, or does A implement B's contract? Yes, extends a class
- Inheritance
How to tell them apart
- Dependency or association? Ignore the class names and look for a field. If
AstoresB, it’s an association; ifBappears only in a parameter, local or return type, it’s a dependency. - Composition or aggregation? “Has” doesn’t mean “owns”. It’s composition only if
Bdies withAand no other object shares it. IfBis passed in, shared or survivesA, it’s aggregation. - Association class? Look for data that belongs to the pairing rather than either side, such as a quantity, status, date or role. That data needs a class of its own, such as
EnrollmentorCartItem. - Inheritance or realization? Check what the parent is. Extending a class is inheritance; implementing an interface, protocol or trait is realization.
- Two questions that look alike? Find the one fact that differs between them; that fact decides the answer.
Patterns are arrangements of these relationships
Most design patterns are fixed arrangements of the relationships from Part 1, which makes them much easier to learn once the relationships are second nature:
Green boxes are interfaces or abstract types. Composite and Decorator share one trick: a class that both is a type and holds that same type.
Strategy and Observer have the same shape; the differences are one holder versus many, and the intent. Seen this way, you can recognize a pattern in unfamiliar code just from how its types connect. Parts 3 to 5 draw every pattern this way.
Part 3: Creational patterns
Who gets to call new? Creating an object hides three decisions: how many to make, which concrete type, and how to put it together. Each creational pattern takes over one of them.
| Pattern | Decides | In one line |
|---|---|---|
| Singleton | How many? | Exactly one, shared by everyone. |
| Factory | Which type? | Decided in one place, returned as an interface. |
| Builder | How assembled? | Step by step, checked once at the end. |
Reading the diagrams
Each pattern gets a class diagram. The arrows are the relationships from Part 1, and this notation holds for the rest of the post.
Go has no classes, so read a class box as a struct or an interface. Exported (capitalized) names are +, unexported names are -.
Singleton
How many? Make exactly one instance, and give the whole program one way to reach it.
- Reach for it when: A second copy would waste or corrupt something: a connection pool, a metrics registry, a cache, a loaded config.
- Go idiom:
sync.Once, orsync.OnceValuein Go 1.21+ - Python idiom: A module-level object. Modules are already singletons.
Class diagram
In UML, a singleton is a class that keeps a reference to its only instance and exposes a static accessor. Go has no static members, so the “static” parts are package-level variables.
Config keeps a reference to its only instance (the arrow that loops back to itself, multiplicity 1). Callers never construct a Config; they call Get(), which is underlined because it belongs to the package, not to an instance.
The race it has to survive
The obvious version checks for nil and then creates. Two goroutines can both pass the check before either one assigns, and now you have two objects. This is the most common singleton bug.
go test -race flags the naive version. sync.Once makes the second caller wait for the first, then skip the work entirely.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package config
import (
"os"
"sync"
)
type Config struct {
DSN string
MaxConns int
}
var (
instance *Config // the one and only
once sync.Once // guards the first creation
)
// Get returns the shared Config. Safe to call from any goroutine.
func Get() *Config {
once.Do(func() {
instance = &Config{
DSN: os.Getenv("DB_DSN"),
MaxConns: 20,
}
})
return instance
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config.py
# The Pythonic singleton is a module-level object. Python runs a module
# once and caches it in sys.modules, so every import gets the same object.
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
dsn: str
max_conns: int = 20
settings = Config(dsn=os.environ.get("DB_DSN", ""))
# anywhere else: `from config import settings` gets this same object
Where you’ll find it: http.DefaultClient and slog.Default() (shared package-level defaults), prometheus.MustRegister (one global registry, which panics on a duplicate metric), and logging.getLogger("app") (one logger per name, process-wide).
Traps
Hidden dependencies. A function that calls config.Get() inside hides that dependency from its signature, and a test can’t swap it. Create the instance once in main and pass it down: still exactly one, with no global lookup.
One per process, not one per system. Three replicas, four gunicorn workers or a multiprocessing pool each get their own copy. Shared state belongs in a database or Redis, and one actor across replicas needs leader election (client-go’s leaderelection with a Lease).
A failed first initialization is cached. sync.Once never retries. Use sync.OnceValues so every caller at least gets the error, or load in main and exit on failure.
The GIL doesn’t make check-then-create safe. A thread can be switched out between the is None check and the assignment. A class-based singleton needs a lock and a second check inside it; the module-level object needs neither.
Factory
Which type? Put the decision about which concrete type to create in one place, and hand back an interface.
- Reach for it when: The concrete type depends on config or input (storage backend, cloud, payment provider), or the same
switchon type shows up in several places. - Go idiom:
func NewStore(kind string) (Store, error), returning an interface - Python idiom: A dict of classes, filled by a
@registerdecorator
Class diagram
Uploader depends only on the Store interface. NewStore is the one place that knows the three concrete types exist: it creates them («create») and returns them as Store.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package storage
import (
"context"
"fmt"
)
// Store is all the caller depends on.
type Store interface {
Put(ctx context.Context, key string, data []byte) error
Get(ctx context.Context, key string) ([]byte, error)
}
type S3Store struct{ bucket string }
type GCSStore struct{ bucket string }
type LocalStore struct{ root string }
// (each type implements Put and Get; omitted here)
// NewStore is the factory. The caller names what it wants;
// this function decides which concrete type that means.
func NewStore(kind, target string) (Store, error) {
switch kind {
case "s3":
return &S3Store{bucket: target}, nil
case "gcs":
return &GCSStore{bucket: target}, nil
case "local":
return &LocalStore{root: target}, nil
default:
return nil, fmt.Errorf("unknown store kind %q", kind)
}
}
1
2
3
4
5
6
// kind comes from config, so switching backends is a config change.
store, err := storage.NewStore(cfg.Storage.Kind, cfg.Storage.Target)
if err != nil {
log.Fatalf("storage: %v", err) // fail at startup, not on the first upload
}
uploader := NewUploader(store) // Uploader only knows the Store interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from typing import Callable, Protocol
class Store(Protocol):
def put(self, key: str, data: bytes) -> None: ...
def get(self, key: str) -> bytes: ...
_REGISTRY: dict[str, Callable[[str], Store]] = {}
def register(kind: str):
"""Class decorator: adds the class to the factory under `kind`."""
def wrap(cls):
_REGISTRY[kind] = cls
return cls
return wrap
@register("s3")
class S3Store:
def __init__(self, bucket: str):
self.bucket = bucket
def put(self, key, data): ...
def get(self, key): ...
def new_store(kind: str, target: str) -> Store:
try:
return _REGISTRY[kind](target)
except KeyError:
raise ValueError(f"unknown store kind {kind!r}") from None
store = new_store("s3", "logs-bucket") # an S3Store, typed as Store
A Go detail worth remembering: the usual advice is “accept interfaces, return structs”, so a plain constructor like NewS3Store() returns *S3Store. A factory is the exception. Its whole job is that the caller doesn’t know which struct it gets, so it has to return the interface.
Making it open: the registry factory
A switch has to be edited for every new backend. In the registry version each backend adds itself, so the factory never changes. database/sql works exactly this way.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package storage
import (
"fmt"
"sync"
)
type Constructor func(target string) (Store, error)
var (
mu sync.RWMutex
factories = map[string]Constructor{}
)
// Register is called from each backend's init().
func Register(kind string, c Constructor) {
mu.Lock()
defer mu.Unlock()
if _, dup := factories[kind]; dup {
panic("storage: Register called twice for " + kind)
}
factories[kind] = c
}
func NewStore(kind, target string) (Store, error) {
mu.RLock()
c, ok := factories[kind]
mu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown store kind %q (missing import?)", kind)
}
return c(target)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package s3store
import "example.com/app/storage"
type S3Store struct{ bucket string }
// (S3Store implements Put and Get; omitted here)
// Adds itself to the factory when the package is imported.
func init() {
storage.Register("s3", func(bucket string) (storage.Store, error) {
return &S3Store{bucket: bucket}, nil
})
}
// main.go
import _ "example.com/app/storage/s3store" // imported only to run its init()
That’s why import _ "github.com/lib/pq" exists: the blank import runs pq’s init(), which registers the “postgres” driver before main starts. sql.Open only looks the name up, and no connection opens until the first query. In Python, importing the module that carries the @register decorator does the same.
Three things called “factory”
People say “factory” for three different things. All three answer “which type?”, but they put the decision in different places.
| Simple factory | Factory Method | Abstract Factory | |
|---|---|---|---|
| What it is | A function with a switch | A method that subclasses override | An object that makes a whole family |
| Decides | One type per call | One type per subclass | A matching set of types |
| Go shape | NewStore(kind) |
A func field you inject |
An interface with several New… methods |
Factory Method
In a Factory Method, a base type writes the whole algorithm once and leaves one step, “create the thing I’ll work with”, to subclasses. Below, Exporter owns the batching; each subclass only decides which sink the batches go to.
Solid line with a hollow triangle means inherits. Exporter.export() calls create_sink(), which is abstract (italic). Each subclass overrides only that one method, and each creates its own sink.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Go has no inheritance, so the "factory method" becomes a function
// field you inject instead of a method you override.
type Sink interface {
Write(batch []Record) error
}
type Exporter struct {
newSink func() Sink
}
func (e Exporter) Export(records []Record) error {
sink := e.newSink() // the factory method
for i := 0; i < len(records); i += 100 {
if err := sink.Write(records[i:min(i+100, len(records))]); err != nil {
return err
}
}
return nil
}
s3 := Exporter{newSink: func() Sink { return &S3Sink{bucket: "logs"} }}
cw := Exporter{newSink: func() Sink { return &CloudWatchSink{group: "/app"} }}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from abc import ABC, abstractmethod
class Exporter(ABC):
def export(self, records: list[dict]) -> None:
sink = self.create_sink() # the factory method
for i in range(0, len(records), 100):
sink.write(records[i:i + 100]) # batching is written once, here
@abstractmethod
def create_sink(self) -> "Sink": ...
class S3Exporter(Exporter):
def create_sink(self) -> "Sink":
return S3Sink() # CloudWatchExporter returns a CloudWatchSink
S3Exporter().export([{"msg": "hi"}] * 250) # 3 batches to S3
Abstract Factory
When products come in families that must match, one factory object makes the whole family. A provisioner that works on both clouds is the classic case: a site on AWS needs an EC2 instance, an S3 bucket and an SQS queue, and none of them may come from Azure.
Each row is one concrete factory; each column is one product interface. Choosing a factory chooses a whole row, so an EC2 VM writing to an Azure blob container can’t happen.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// One interface per product...
type VM interface{ Start(ctx context.Context) error }
type Bucket interface{ Put(ctx context.Context, key string, b []byte) error }
type Queue interface{ Send(ctx context.Context, msg []byte) error }
// ...and one factory interface that makes a matching family of them.
type CloudFactory interface {
NewVM(size string) VM
NewBucket(name string) Bucket
NewQueue(name string) Queue
}
type AWSFactory struct{ Region string }
func (f AWSFactory) NewVM(size string) VM { return &EC2Instance{region: f.Region, size: size} }
func (f AWSFactory) NewBucket(name string) Bucket { return &S3Bucket{region: f.Region, name: name} }
func (f AWSFactory) NewQueue(name string) Queue { return &SQSQueue{region: f.Region, name: name} }
// AzureFactory mirrors it with AzureVM, BlobContainer and ServiceBusQueue.
// provisionSite never names a cloud, and everything it makes matches.
func provisionSite(ctx context.Context, f CloudFactory) error {
vm := f.NewVM("large")
logs := f.NewBucket("site-logs")
jobs := f.NewQueue("site-jobs")
if err := logs.Put(ctx, "provisioned", nil); err != nil {
return err
}
if err := jobs.Send(ctx, []byte("boot")); err != nil {
return err
}
return vm.Start(ctx)
}
The factory itself is picked once at startup, usually by a simple factory that switches on "aws" or "azure". Adding a family, a third cloud, is cheap: one new factory. Adding a product, say NewDatabase, is the expensive direction: the interface and every concrete factory change.
Where you’ll find it: sql.Open("pgx", dsn) and image.Decode (registries filled by init()), boto3.client("s3"), and SQLAlchemy’s create_engine(url), which picks the dialect from the URL scheme.
Traps
A factory for one type. That’s indirection with no decision in it. Add the factory when the second type shows up.
Unknown kinds found late. Build the store in main and exit on error, so a typo in config fails the deploy instead of the first request.
A switch that keeps growing. Move to a registry so new types add themselves, and the factory stops changing.
Leaking the concrete type. If callers write store.(*S3Store), the interface isn’t doing its job. Add the method they need to Store, or rethink the split.
Builder
How assembled? Assemble a complex object one named step at a time, and validate it once at the end.
- Reach for it when: A constructor needs many parameters, most of them optional, or some combinations are invalid.
- Go idiom: A fluent builder for multi-step objects; functional options for constructors with defaults
- Python idiom: Keyword arguments cover most cases; builders shine for step-by-step assembly like query builders
The problem it removes
Before: telescoping constructor
1
2
p := NewPod("api", "nginx:1.27", 8080,
nil, 3, true, "", 0)
What is true? What does "" turn off? Swap the 3 and the 0 and it still compiles.
After: builder
1
2
3
4
5
p, err := pod.New("api").
Image("nginx:1.27").
Port(8080).
Replicas(3).
Build()
Every value has a name. Anything you skip keeps its default. Build() refuses a Pod with no image.
Class diagram
PodBuilder collects settings and errors, and creates the Pod only in Build(). The textbook version adds a Director that runs a fixed recipe over a builder interface; in Go the calling code usually plays that role.
What Build() reports
What the builder below prints. Build() reports every problem at once with errors.Join, and returns a copy, so later builder calls don’t reach a Pod already built.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
p, err := pod.New("api").Image("nginx:1.27").Port(8080).Build()
// p: {Name:api Image:nginx:1.27 Ports:[8080] Env:map[] Replicas:1}
// err: <nil>
_, err = pod.New("api").Port(8080).Build()
// err: image is required
_, err = pod.New("api").Port(70000).Build()
// err: port 70000 out of range
// image is required
b := pod.New("api").Image("nginx:1.27")
first, _ := b.Build()
b.Env("LOG_LEVEL", "debug").Port(9090)
second, _ := b.Build()
// first: {Name:api Image:nginx:1.27 Ports:[] Env:map[] Replicas:1}
// second: {Name:api Image:nginx:1.27 Ports:[9090] Env:map[LOG_LEVEL:debug] Replicas:1}
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package pod
import (
"errors"
"fmt"
"maps"
"slices"
)
type Pod struct {
Name string
Image string
Ports []int
Env map[string]string
Replicas int
}
type PodBuilder struct {
pod Pod
errs []error
}
func New(name string) *PodBuilder {
return &PodBuilder{pod: Pod{Name: name, Replicas: 1, Env: map[string]string{}}}
}
func (b *PodBuilder) Image(ref string) *PodBuilder {
b.pod.Image = ref
return b // returning b is what lets calls chain
}
func (b *PodBuilder) Port(n int) *PodBuilder {
if n < 1 || n > 65535 {
b.errs = append(b.errs, fmt.Errorf("port %d out of range", n))
}
b.pod.Ports = append(b.pod.Ports, n)
return b
}
func (b *PodBuilder) Env(key, value string) *PodBuilder {
b.pod.Env[key] = value
return b
}
func (b *PodBuilder) Replicas(n int) *PodBuilder {
b.pod.Replicas = n
return b
}
// Build checks everything once and hands back an independent copy.
func (b *PodBuilder) Build() (Pod, error) {
errs := slices.Clone(b.errs)
if b.pod.Image == "" {
errs = append(errs, errors.New("image is required"))
}
if err := errors.Join(errs...); err != nil {
return Pod{}, err
}
p := b.pod
p.Ports = slices.Clone(b.pod.Ports)
p.Env = maps.Clone(b.pod.Env)
return p, nil
}
The Go idiom: functional options
For constructors that mostly need sensible defaults plus a few overrides, Go code usually reaches for functional options instead of a builder object. Each option is a small function that edits the struct being created.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Server struct {
addr string
timeout time.Duration
tls *tls.Config
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithTLS(c *tls.Config) Option { return func(s *Server) { s.tls = c } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second} // defaults first
for _, opt := range opts {
opt(s) // then each option edits the struct
}
return s
}
srv := NewServer(":8443", WithTimeout(5*time.Second), WithTLS(tlsCfg))
Pick functional options when the object is ready in one call and each option stands alone. Pick a fluent builder when assembly happens in steps, options depend on each other, or you want to collect several errors and report them together.
Where you’ll find it: client-go apply configurations (corev1ac.Pod(…).WithSpec(…)), strings.Builder, SQLAlchemy’s select(…).where(…), and functional options in grpc.NewClient and the AWS SDK for Go v2.
Traps
A builder for a small object. Two required fields? A struct literal or a plain constructor is clearer. In Python, a dataclass with defaults and keyword arguments covers most cases.
A Build() that checks nothing. Then the builder is setters with extra steps. The promise is “if you got a Pod back, it’s valid”.
Shared slices and maps. Copy them in Build() with slices.Clone and maps.Clone. Otherwise reusing the builder quietly edits the Pod you already returned.
Sharing a builder between goroutines. Builders are mutable and unsynchronized. Make one per construction.
They combine
Real libraries stack them. Spark’s session setup is a builder that ends in a singleton:
1
2
3
4
spark = (SparkSession.builder # Builder: collect settings
.appName("etl")
.config("spark.sql.shuffle.partitions", "64")
.getOrCreate()) # Singleton: reuse the active session if there is one
Likewise, sql.Open is a registry factory, and the *sql.DB it returns is meant to be opened once and shared: a singleton by convention.
Check your understanding
-
1
Every HTTP handler opens its own
*sql.DB, and Postgres starts refusing connections. Which pattern fixes it?Show answer
Answer Singleton
You need one shared pool per process. In Go, open one
*sql.DBinmain, or behindsync.Once, and pass it to the handlers. A*sql.DBis already a pool and is safe for concurrent use. -
2
Config says
storage.kind: s3orlocal. The upload code shouldn’t care which.Show answer
Answer Factory
The concrete type depends on config.
NewStore(kind)decides once, in one place, and hands back theStoreinterface, so the uploader never names a concrete type. -
3
A load balancer config has 15 optional settings, and an HTTPS listener is invalid without a certificate.
Show answer
Answer Builder
Many optional fields plus a rule that spans fields. Collect the settings step by step, and enforce the cross-field rule in
Build(), the one place that sees everything before the object exists. -
4
The provisioner creates a VM, a bucket and a queue, and all three must come from the same cloud.
Show answer
Answer Abstract Factory
A family of products that must match. Pick one factory, AWS or Azure, and every product comes from that row, so mixing clouds can’t happen.
-
5
A base
Exporterbatches records. Each subclass only decides where the batches are written.Show answer
Answer Factory Method
The algorithm, batching, is written once in the base type, and the one creation step,
create_sink(), is left to subclasses. In Go the same idea is afuncfield you inject. -
6
Only one replica of your controller may act at a time, across a 3-replica Deployment.
Show answer
Answer None of these
A singleton is one per process, so three replicas means three instances. One actor across replicas needs leader election, such as client-go’s
leaderelectionwith a Kubernetes Lease. -
7
A
Pointstruct with two required fields,XandY.Show answer
Answer None of these
A struct literal,
Point{X: 1, Y: 2}, is already clear. A builder or a factory here only adds ceremony. -
8
SparkSession.builder.appName("etl").getOrCreate()uses which two patterns?Show answer
Answer Builder + Singleton
The chain collects settings, which is a builder, and
getOrCreate()returns the active session if one already exists, which is a singleton. -
9
A Go service whose
config.Get()is guarded bysync.Onceruns as a Deployment with 3 replicas. How manyConfigobjects exist?Show answer
Answer Three, one per replica
A singleton is one per process, and each replica is its own process with its own memory. Gunicorn workers and
multiprocessingpools behave the same way. Shared state across replicas belongs in a database or Redis. -
10
The function passed to
sync.Oncefails on its first run because the config file is missing. What do later calls get?Show answer
Answer Nothing new: the function never runs again, so they get the failed result
sync.Onceruns the function once, whatever happens. Usesync.OnceValuesso every caller at least sees the error, or load the config inmainand exit on failure. -
11
Two goroutines call a singleton getter that does
if instance == nil { instance = load() }. What can go wrong?Show answer
Answer Both can see
nilbefore either assigns, so two instances get createdCheck-then-create is the most common singleton bug, and
go test -raceflags it.sync.Oncemakes the second caller wait for the first and then skip the work. In Python, the GIL doesn’t save you either: a thread can be switched out between the check and the assignment. -
12
What is the Pythonic way to get a singleton?
Show answer
Answer A module-level object, such as
settings = Config(...)inconfig.pyPython runs a module once and caches it in
sys.modules, so everyimportgets the same object. A class-based singleton needs a lock and a double check to be thread-safe. -
13
Go style says “accept interfaces, return structs”. Why does
NewStore(kind)return theStoreinterface anyway?Show answer
Answer Its whole job is that the caller doesn’t know which struct it gets
A plain constructor such as
NewS3Store()returns*S3Store. A factory is the exception: hiding the concrete type is the point. If callers start writingstore.(*S3Store), the interface isn’t doing its job. -
14
What does
import _ "github.com/lib/pq"do?Show answer
Answer Runs pq’s
init(), which registers the “postgres” driver withdatabase/sqlA blank import runs the package for its side effects.
database/sqlis a registry factory: drivers add themselves by name, andsql.Openonly looks the name up. No connection opens until the first query. -
15
Why does the
Podbuilder’sBuild()copy its slices and maps withslices.Cloneandmaps.Clone?Show answer
Answer Otherwise reusing the builder would quietly edit Pods it already returned
A copied struct still shares its slices and maps with the original. Without the clones,
b.Env(...)after aBuild()would change the first Pod too. Builders are still not safe to share between goroutines; make one per construction. -
16
When is a fluent builder a better fit than Go’s functional options?
Show answer
Answer When assembly happens in steps, options depend on each other, or you want to report several errors together
Functional options suit objects that are ready in one call, where each option stands alone. A fluent builder can collect state across steps and check everything once in
Build(), as the Pod builder does witherrors.Join. -
17
You add a fourth product,
NewDatabase, to an Abstract Factory with AWS and Azure implementations. What has to change?Show answer
Answer The
CloudFactoryinterface and every concrete factoryAbstract Factory makes adding a family, such as a third cloud, cheap: one new factory. Adding a product is the expensive direction, because every family must learn to make it.
Part 4: Structural patterns
What’s standing in the middle? Most structural patterns put one object between the caller and another. They differ in what that middle object changes: the shape of the call, the behavior around it, whether it gets through, or how much of the system you see. The shape to watch for on a diagram is a type that implements an interface and also holds a field of that same interface.
| Pattern | Changes | In one line |
|---|---|---|
| Adapter | The shape | Makes one object fit an interface it wasn’t built for. |
| Decorator | The behavior | Same interface, extra work before or after each call. |
| Proxy | The access | Same interface, decides whether and when calls get through. |
| Facade | The surface | One simple front door to a whole subsystem. |
| Composite | The count | One item or a whole tree, same call. |
| Bridge | The axes | A type that varies two ways becomes two hierarchies joined by a field. |
| Flyweight | The memory | Many objects share one copy of identical state. |
Read each row left to right. Adapter: the call leaving the middle is a different call. Decorator: the same call, maybe several times. Proxy: the same call, or none at all when it can answer itself. Facade: one call in, four out.
Adapter
Changes the shape. Make an existing object usable through an interface it wasn’t written for, without changing either side.
- Reach for it when: You integrate a vendor SDK, legacy code or a second provider, and you don’t want their API spread through your code.
- Go idiom: A small struct that holds the foreign type and implements your interface
- Python idiom: A class that wraps the foreign object and exposes your method names
Class diagram
This is the object adapter: it holds the vendor client in a field. The class adapter variant inherits from the adaptee instead; Go can’t do that, and in Python it’s rarely worth it.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package alert
import (
"context"
"fmt"
"github.com/slack-go/slack"
)
type Severity int
const (
Info Severity = iota
Critical
)
type Alert struct {
Title string
Body string
Severity Severity
}
// Notifier is our interface, defined by the code that uses it.
type Notifier interface {
Notify(ctx context.Context, a Alert) error
}
// SlackNotifier adapts *slack.Client to Notifier.
type SlackNotifier struct {
client *slack.Client
channel string
}
func (s *SlackNotifier) Notify(ctx context.Context, a Alert) error {
text := a.Title + "\n" + a.Body
if a.Severity == Critical {
text = ":rotating_light: " + text
}
_, _, err := s.client.PostMessageContext(ctx, s.channel, slack.MsgOptionText(text, false))
if err != nil {
return fmt.Errorf("slack notify: %w", err) // don't leak the vendor's error types
}
return nil
}
// Compile-time check that SlackNotifier implements Notifier.
var _ Notifier = (*SlackNotifier)(nil)
1
2
3
4
5
6
7
8
9
10
11
// The standard library ships an adapter you use every day:
//
// type HandlerFunc func(ResponseWriter, *Request)
// func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
//
// It turns a plain function into an http.Handler.
func health(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
http.Handle("/healthz", http.HandlerFunc(health))
In Go, define the interface where it’s used, with only the methods that code needs. Then the adapter stays tiny, and tests can swap in a fake Notifier without touching Slack.
Where you’ll find it: http.HandlerFunc (Go’s docs call it an adapter), strings.NewReader, Python’s io.TextIOWrapper, dockershim (CRI to Docker, until Kubernetes 1.24), and any MCP server in front of a REST API.
Traps
Business logic creeping in. Keep the adapter to translation. If it starts deciding who gets paged, move that rule into your service, where every vendor benefits.
Vendor types leaking out. Return your own errors and types. Wrap with %w so callers can still inspect the cause if they must.
An interface shaped like the first vendor. If Notifier mirrors Slack’s options, the PagerDuty adapter gets awkward. Design the interface from what your code needs.
Wrapping the whole SDK. Adapt only the calls you use. A three-method interface is easy to adapt and easy to fake.
Decorator
Adds behavior. Wrap an object in another with the same interface, adding behavior before or after each call. Stack as many as you like.
- Reach for it when: You need logging, retries, metrics, caching, timeouts or compression around existing code, in combinations that change.
- Go idiom: Middleware:
func(http.Handler) http.Handler; or a struct with anextfield - Python idiom: The
@decoratorsyntax, which is this pattern applied to functions
Class diagram
The shape to remember: a decorator is a Sender (it implements the interface) and has a Sender (its next field). Because the wrapped thing has the same type as the wrapper, wrappers nest without limit.
Dashed line with a hollow triangle: implements. Solid line with a hollow triangle: extends. The accent arrow is the next field, pointing back at the interface.
Order is part of the design
The same three layers in two orders, with the first SMTP attempt failing. Indentation shows nesting.
Retry inside Logging: Logging(Metrics(Retry(email)))
1
2
3
4
5
6
7
8
logging → Send(order-1042)
retry: attempt 1
email: SMTP timeout
retry: wait 100ms
retry: attempt 2
email: 250 OK
metrics: send_total{result="ok"} +1
logging ← nil
1 log line, 1 metric increment, 2 SMTP attempts.
Retry outside Logging: Retry(Logging(Metrics(email)))
1
2
3
4
5
6
7
8
9
10
11
retry: attempt 1
logging → Send(order-1042)
email: SMTP timeout
metrics: send_total{result="error"} +1
logging ← err: SMTP timeout
retry: wait 100ms
retry: attempt 2
logging → Send(order-1042)
email: 250 OK
metrics: send_total{result="ok"} +1
logging ← nil
2 log lines, 2 metric increments, 2 SMTP attempts. With Retry inside Logging you get one log line per call; with Retry outside, one per attempt. Order matters elsewhere too: with Auth outside Logging, rejected requests never get logged.
Code
In Go the same shape is usually HTTP middleware: a function that takes an http.Handler and returns one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package web
import (
"log/slog"
"net/http"
"time"
)
// Logging is a decorator: it is an http.Handler, and it wraps one.
func Logging(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // the wrapped handler does the real work
log.Info("request", "method", r.Method, "path", r.URL.Path, "took", time.Since(start))
})
}
}
// Wrappers stack, and the outermost runs first:
// http.Handle("/orders", Logging(logger)(Auth(orders)))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import functools
import logging
import time
from tenacity import retry, stop_after_attempt
def logged(fn):
@functools.wraps(fn) # keep fn's name and docstring
def inner(*args, **kwargs):
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
logging.info("%s took %.1f ms", fn.__name__, (time.perf_counter() - start) * 1000)
return inner
@logged # outer layer: listed first, runs first
@retry(stop=stop_after_attempt(3)) # inner layer
def send_email(to: str, body: str) -> None:
...
Where you’ll find it: HTTP middleware and http.TimeoutHandler, gzip.NewReader and bufio.NewReader, otelhttp.NewTransport, gRPC interceptors, and Python’s @functools.lru_cache.
Traps
Optional interfaces disappear. Wrapping http.ResponseWriter hides http.Flusher and http.Hijacker, so streaming and websockets quietly break behind your middleware. Give the wrapper an Unwrap() http.ResponseWriter method so http.ResponseController can reach the original.
Identity checks break. A wrapped value is a different value. == comparisons and type assertions against the concrete type fail.
Forgetting functools.wraps. Without it the wrapper replaces the function’s name and docstring, which confuses logs, debuggers and frameworks that register things by name.
Proxy
Controls access. Stand in for another object behind the same interface, and decide whether, when and how calls reach it.
- Reach for it when: The real object is expensive to create, lives on another machine, needs protecting, or keeps getting asked the same question.
- Go idiom: A struct with the same interface that holds, or lazily builds, the real one;
httputil.ReverseProxy - Python idiom: A class with the same methods; Django’s
SimpleLazyObject
Proxies are named for what they do with a call. A virtual proxy builds the real object on first use, a caching proxy answers repeats itself, a protection proxy refuses unauthorized calls, and a remote proxy forwards over the network. One proxy can be several; the one below is virtual and caching.
Class diagram
Structurally the same as a decorator: implements Catalog and holds a Catalog. The difference is that this one owns its target and may not forward at all.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package catalog
import (
"context"
"sync"
)
type Item struct {
ID string
Price int
}
type Catalog interface {
Get(ctx context.Context, id string) (Item, error)
}
// CachingCatalog is a proxy: the same interface as the real catalog,
// but it decides whether a call reaches the real one at all.
type CachingCatalog struct {
newReal func() (Catalog, error) // virtual proxy: build the real one on first use
once sync.Once
real Catalog
realErr error
mu sync.Mutex
cache map[string]Item
}
func NewCachingCatalog(newReal func() (Catalog, error)) *CachingCatalog {
return &CachingCatalog{newReal: newReal, cache: map[string]Item{}}
}
func (p *CachingCatalog) Get(ctx context.Context, id string) (Item, error) {
p.mu.Lock()
it, ok := p.cache[id]
p.mu.Unlock()
if ok {
return it, nil // answered without touching the real catalog
}
backend, err := p.backend()
if err != nil {
return Item{}, err
}
it, err = backend.Get(ctx, id) // the slow network call happens outside the lock
if err != nil {
return Item{}, err
}
p.mu.Lock()
p.cache[id] = it
p.mu.Unlock()
return it, nil
}
func (p *CachingCatalog) backend() (Catalog, error) {
p.once.Do(func() { p.real, p.realErr = p.newReal() })
return p.real, p.realErr
}
The rest of the diagram is the real catalog, and the Checkout that can’t tell it from the proxy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// catalog/remote.go
// RemoteCatalog is the real object: every Get is a network call.
type RemoteCatalog struct {
conn *grpc.ClientConn
}
func NewRemoteCatalog(conn *grpc.ClientConn) *RemoteCatalog {
return &RemoteCatalog{conn: conn}
}
// (RemoteCatalog implements Get with an RPC over conn; omitted here)
// checkout/checkout.go
// Checkout depends only on the Catalog interface.
type Checkout struct {
catalog catalog.Catalog
}
func New(c catalog.Catalog) *Checkout {
return &Checkout{catalog: c}
}
func (c *Checkout) Price(ctx context.Context, id string) (int, error) {
it, err := c.catalog.Get(ctx, id)
return it.Price, err
}
// main.go: Checkout gets the proxy; the remote catalog is created on first use.
proxy := catalog.NewCachingCatalog(func() (catalog.Catalog, error) {
conn, err := grpc.NewClient("catalog:443",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return catalog.NewRemoteCatalog(conn), nil
})
co := checkout.New(proxy)
Where you’ll find it: httputil.ReverseProxy, Envoy and NGINX (remote), gRPC client stubs (remote), and Django’s request.user or SQLAlchemy lazy relationships (virtual).
Traps
Holding a lock across the remote call. Every caller then waits behind the slowest request. Check the cache under the lock, call outside it, and store under the lock again, as the code does.
A stampede on a cold cache. A hundred concurrent misses for one key make a hundred backend calls. golang.org/x/sync/singleflight collapses them into one.
Stale answers. A cache needs a TTL or an invalidation rule. Decide which before you ship.
A failed lazy init stays failed. sync.Once never retries, as in the Singleton traps. If creating the real object can fail transiently, use a mutex and retry.
Facade
Shrinks the surface. Give a complicated subsystem one simple front door, so most callers never have to learn the parts behind it.
- Reach for it when: Several callers repeat the same multi-step sequence across subsystems, or using a module means learning too many of its parts.
- Go idiom: A struct with a few high-level methods that holds the subsystem clients
- Python idiom: A class or module-level function; a package’s
__init__.pyre-exporting the simple path
Class diagram
The facade holds the subsystems and offers two methods. It doesn’t hide them: the dashed path shows an advanced caller going straight to Registry.
Code
An infrastructure facade has a job a textbook one doesn’t: when step three fails, steps one and two have already changed the world. This one records an undo for each finished step and runs them in reverse on failure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package sites
import (
"context"
"fmt"
)
type Spec struct {
Name, VPC, CIDR, Size string
Nodes int
}
type Site struct {
Name string
Subnet string
Nodes []string
}
// The subsystems, each with its own detailed API.
type Network interface {
CreateSubnet(ctx context.Context, vpcID, cidr string) (string, error)
DeleteSubnet(ctx context.Context, subnetID string) error
}
type Compute interface {
LaunchNodes(ctx context.Context, subnetID, size string, n int) ([]string, error)
TerminateNodes(ctx context.Context, ids []string) error
}
type DNS interface {
UpsertRecord(ctx context.Context, name string, targets []string) error
}
type Registry interface {
Save(ctx context.Context, s Site) error
}
// SiteProvisioner is the facade: one call instead of four services.
type SiteProvisioner struct {
net Network
vm Compute
dns DNS
reg Registry
}
func (p *SiteProvisioner) Provision(ctx context.Context, spec Spec) (site Site, err error) {
var undo []func() // compensating steps, run in reverse if a later step fails
defer func() {
if err != nil {
for i := len(undo) - 1; i >= 0; i-- {
undo[i]()
}
}
}()
subnet, err := p.net.CreateSubnet(ctx, spec.VPC, spec.CIDR)
if err != nil {
return Site{}, fmt.Errorf("create subnet: %w", err)
}
undo = append(undo, func() { _ = p.net.DeleteSubnet(context.WithoutCancel(ctx), subnet) })
nodes, err := p.vm.LaunchNodes(ctx, subnet, spec.Size, spec.Nodes)
if err != nil {
return Site{}, fmt.Errorf("launch nodes: %w", err)
}
undo = append(undo, func() { _ = p.vm.TerminateNodes(context.WithoutCancel(ctx), nodes) })
site = Site{Name: spec.Name + ".sites.example.com", Subnet: subnet, Nodes: nodes}
if err = p.dns.UpsertRecord(ctx, site.Name, nodes); err != nil {
return Site{}, fmt.Errorf("dns record: %w", err)
}
if err = p.reg.Save(ctx, site); err != nil {
return Site{}, fmt.Errorf("register: %w", err)
}
return site, nil
}
The undo steps use context.WithoutCancel(ctx), so cleanup still runs when the original request was cancelled, which is often the very reason a step failed.
Where you’ll find it: os.ReadFile, requests.get, boto3’s upload_file (multipart upload, parallel parts and per-part retries behind one call), kubectl apply, and Terraform modules.
Traps
The god facade. Sixty methods means it has become a second copy of the subsystem. Split facades by use case.
Partial failure. Decide what happens when step three fails: undo in reverse, as above, or make every step idempotent so a retry is safe.
Blocking the escape hatch. Most callers want the simple path; a few need the details. Leave the subsystems reachable.
Composite
Hides the count. Arrange objects in a tree and let callers treat a single item and a whole group exactly the same way.
- Reach for it when: The data is naturally a tree (resources, UI widgets, org charts, rule sets) and you keep writing “if it’s a group, loop; otherwise…”.
- Go idiom: One interface implemented by the leaf and by a group that holds
[]Interface - Python idiom: The same, with a
childrenlist; duck typing means no shared base is required
Class diagram
The giveaway shape: Group implements Resource and holds many Resources. That one aggregation arrow pointing back at the interface is what makes the structure recursive.
* on the aggregation means a group holds any number of children, and each child may itself be a group. The hollow diamond fits the convention from Part 1: a pod can outlive any cost group it’s counted in.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package capacity
type Resource interface {
Name() string
CPU() int // millicores requested
}
// Pod is a leaf.
type Pod struct {
name string
millicpu int
}
func (p Pod) Name() string { return p.name }
func (p Pod) CPU() int { return p.millicpu }
// Group is the composite: it is a Resource, and it holds Resources.
type Group struct {
name string
children []Resource
}
func (g *Group) Name() string { return g.name }
func (g *Group) CPU() int {
total := 0
for _, c := range g.children {
total += c.CPU() // a child may be a Pod or another Group; no type switch
}
return total
}
func (g *Group) Add(rs ...Resource) *Group {
g.children = append(g.children, rs...)
return g
}
1
2
3
4
5
payments := (&Group{name: "payments"}).Add(Pod{"api-1", 250}, Pod{"api-2", 250}, Pod{"worker", 300})
search := (&Group{name: "search"}).Add(Pod{"indexer", 1000}, Pod{"query", 200})
prod := (&Group{name: "prod"}).Add(payments, search)
fmt.Println(prod.CPU(), payments.CPU(), Pod{"query", 200}.CPU()) // 2000 800 200
Where you’ll find it: io.MultiWriter, errors.Join (errors.Is walks the whole tree), Kubernetes owner references, scikit-learn’s Pipeline, and Python’s ExceptionGroup.
Traps
Cycles. Adding a group to itself, or to its own descendant, makes CPU() recurse forever. Check in Add.
Add on the shared interface. The “transparent” variant puts Add on Resource so callers never check types, but then Pod must implement Add and fail at runtime. The “safe” variant above keeps it on Group.
Shared children. If one pod sits under two groups, totals count it twice. A composite assumes a tree, not a graph.
Recomputing big trees. Summing 100,000 nodes on every request adds up. Cache totals on groups and invalidate on change.
Bridge
Splits two dimensions. When a type varies along two independent axes, give each axis its own hierarchy and connect them with a field instead of multiplying subclasses.
- Reach for it when: You catch yourself naming types like
IncidentSlackandDigestEmail: two ideas glued into one name. Three message kinds times three channels is nine such types; as a bridge it’s three plus three. - Where you’ll find it:
log/slog: aLoggerholds aHandler.database/sql: aDBholds adriver.Driver.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Implementation side: how a message gets delivered.
type Sender interface {
Deliver(ctx context.Context, to, subject, body string) error
}
// Abstraction side: what kind of message it is.
// Each kind holds a Sender. That field is the bridge.
type Incident struct {
sender Sender
id string
sev int
}
func (m Incident) Send(ctx context.Context, oncall string) error {
subject := fmt.Sprintf("[SEV%d] incident %s", m.sev, m.id)
return m.sender.Deliver(ctx, oncall, subject, "See the runbook.")
}
type Digest struct {
sender Sender
items []string
}
func (m Digest) Send(ctx context.Context, team string) error {
return m.sender.Deliver(ctx, team, "Daily digest", strings.Join(m.items, "\n"))
}
// Any kind × any channel, picked at runtime. Adding Teams means one new Sender.
err := Incident{sender: SlackSender{}, id: "INC-311", sev: 1}.Send(ctx, "#oncall")
Flyweight
Shares identical state. When you have huge numbers of similar objects, share the parts that are identical and store only the differences per object.
- Vocabulary: Intrinsic state is shared and immutable (the label set). Extrinsic state is per object (time and value).
- Where you’ll find it: Prometheus keeps each series’ label set once and appends only (timestamp, value) pairs. CPython caches small ints and interns many strings. Go 1.23 added
unique.Make. - Python idiom: An
@lru_cachefactory that returns frozen dataclasses;sys.internfor strings
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type Labels struct {
Service, Region, Host string
}
// LabelPool is the flyweight factory: one shared *Labels per distinct value.
type LabelPool struct {
mu sync.Mutex
sets map[Labels]*Labels
}
func (p *LabelPool) Get(l Labels) *Labels {
p.mu.Lock()
defer p.mu.Unlock()
if shared, ok := p.sets[l]; ok {
return shared
}
if p.sets == nil {
p.sets = map[Labels]*Labels{}
}
shared := &l
p.sets[l] = shared
return shared
}
// Each sample stores only what differs, plus a pointer to the shared part.
type Sample struct {
labels *Labels // shared: treat as read-only
ts int64
value float64
}
1
2
3
4
5
6
// Go 1.23+: package unique interns comparable values for you,
// and frees them once nothing refers to them any more.
a := unique.Make(Labels{Service: "api", Region: "us-east-1", Host: "ip-10-0-1-7"})
b := unique.Make(Labels{Service: "api", Region: "us-east-1", Host: "ip-10-0-1-7"})
fmt.Println(a == b) // true: one shared copy, compared by pointer
labels := a.Value() // read the shared value
Traps
Mutable shared state. If one sample edits its labels, every sample sharing them changes. The shared part must be immutable.
A pool that never forgets. An interning map that only grows is a memory leak. unique.Make frees values nothing refers to; a plain map doesn’t.
They combine
A few lines of ordinary Go server code hold three of these patterns:
1
2
3
4
5
6
7
backend, err := url.Parse("http://orders.internal:8080")
if err != nil {
log.Fatal(err)
}
rp := httputil.NewSingleHostReverseProxy(backend) // Proxy: the real handler is on another machine
http.Handle("/orders/", Logging(logger)(rp)) // Decorator: same http.Handler, more behavior
// ...and inside Logging, http.HandlerFunc is an Adapter.
Check your understanding
-
1
Your code calls
Notify(ctx, alert); the vendor SDK only offersPostMessageContext(ctx, channel, opts...).Show answer
Answer Adapter
One object behind it, and a different shape on each side. The adapter translates between your interface and the SDK’s, and nothing upstream learns about channels or message options.
-
2
Add request logging and panic recovery to every HTTP handler without editing any of them.
Show answer
Answer Decorator
The same
http.Handlerinterface, extra behavior around each call, and the wrappers stack. Middleware is the Decorator pattern. -
3
The catalog lives in another region. Answer repeat lookups from memory, and don’t dial it at all until the first miss.
Show answer
Answer Proxy
Same interface, and it decides whether and when calls reach the real object: a caching proxy that is also a virtual proxy. A decorator would always forward.
-
4
Five callers each create a subnet, launch nodes, write a DNS record and register the site, in that order.
Show answer
Answer Facade
One simple front door over four subsystems, so the sequence, and what to undo when a step fails, is written once.
-
5
Report CPU for one pod, a namespace or the whole cluster with the same call.
Show answer
Answer Composite
One item or a whole tree behind the same interface. Groups sum their children and never check whether a child is a pod or another group.
-
6
Three alert kinds times three delivery channels, and both lists keep growing.
Show answer
Answer Bridge
Two independent axes. Give each its own hierarchy and connect them with a field, so a new channel is one new type instead of three.
-
7
Ten million metric samples, but only a few thousand distinct label sets.
Show answer
Answer Flyweight
Share the identical, immutable part, the label set, and store only each sample’s time and value.
-
8
http.HandlerFunc(health)Show answer
Answer Adapter
It makes a plain function satisfy
http.Handlerwithout changing the function or the interface. Go’s own documentation calls it an adapter. -
9
gzip.NewReader(file)returns anio.Readerthat decompresses as you read.Show answer
Answer Decorator
An
io.Readerwrapping anio.Readerand adding behavior. You can stackbufio.NewReaderon top of it. -
10
httputil.NewSingleHostReverseProxy(backend)Show answer
Answer Proxy
An
http.Handlerstanding in for a handler on another machine: a remote proxy. -
11
Logging(Metrics(Retry(email)))sends one message, and the first SMTP attempt fails. How many log lines and metric increments do you get?Show answer
Answer 1 log line, 1 metric increment
Retry is the innermost layer, so both attempts happen inside one pass through Metrics and Logging. Put Retry outside,
Retry(Logging(Metrics(email))), and every attempt is logged and counted: 2 and 2. -
12
A middleware wraps
http.ResponseWriterto record the status code. What can quietly break behind it?Show answer
Answer Streaming and websockets, because the wrapper hides
http.Flusherandhttp.HijackerOptional interfaces disappear behind a decorator that doesn’t implement them. Give the wrapper an
Unwrap() http.ResponseWritermethod sohttp.ResponseControllercan reach the original. -
13
Why does
CachingCatalog.Getrelease its mutex before calling the real catalog?Show answer
Answer So callers don’t all wait behind the slowest network request
Check the cache under the lock, call outside it, and store under the lock again. The cost is that concurrent misses for one key each call the backend;
singleflightcollapses those into one call. -
14
A hundred requests miss the cache for the same key at the same moment. What collapses them into one backend call?
Show answer
Answer
golang.org/x/sync/singleflightsingleflightlets the first caller for a key do the work while the others wait and share its result. A TTL doesn’t help a cold cache, andsync.Oncewould never refresh the key. -
15
SiteProvisioner.Provisionhas created a subnet and launched nodes, and then the DNS step fails. What does the facade in this post do?Show answer
Answer Terminates the nodes, then deletes the subnet, and returns the error
Each finished step records an undo, and a deferred function runs them in reverse order on failure. The undo steps use
context.WithoutCancel(ctx), so cleanup still runs when the request itself was cancelled. -
16
On a class diagram, a decorator and a proxy look identical. What separates them?
Show answer
Answer Intent: a decorator always forwards and adds behavior; a proxy may answer, refuse or delay, and often creates its target
Both implement an interface and hold one value of it. A decorator is handed its target and stacks freely, where the order of layers matters. A proxy usually stands alone in front of one object and controls access to it.
-
17
One pod is added under two cost groups, and their parent sums them. What goes wrong?
Show answer
Answer The pod’s CPU is counted twice
A composite assumes a tree, not a graph. A cycle, a group added to its own descendant, is the case that recurses forever; check for it in
Add.
Part 5: Behavioral patterns
Who decides what happens next? Each behavioral pattern takes one decision away from code that would otherwise hard-wire it. Find the decision being moved and you’ve found the pattern.
| Pattern | Decides | In one line |
|---|---|---|
| Strategy | How the work is done | Pick the algorithm at runtime; the caller stays the same. |
| Observer | Who reacts | Everyone who subscribed hears it; the source doesn’t know who they are. |
| State | What a call means now | Same call, different behavior per mode; the object switches itself. |
| Command | When it runs | A call becomes a value you can queue, log, retry or undo. |
| Chain | Who handles it | Each handler takes the request or hands it to the next. |
Five specialists follow: Template Method, Iterator, Mediator, Memento and Visitor.
Strategy
Swap the how. Define a family of interchangeable ways to do one job, and let the caller pick one, even while running.
- Reach for it when: There are several ways to do one job (scaling policies, backoff schedules, compression, pricing rules) and the choice comes from config or changes at runtime.
- Go idiom: A one-method interface, or simply a
funcvalue - Python idiom: A callable passed in (
key=), or a small class per strategy
Three policies, three situations
Three scaling policies answer the same question: how many replicas should run? The autoscaler clamps every answer to 2–20.
| Situation | TargetTracking{Target: 60} |
StepScaling{} |
Scheduled{Peak: 8, OffPeak: 2} |
|---|---|---|---|
| CPU 84%, 4 replicas, 22:00 | 6 | 6 | 2 |
| CPU 45%, 4 replicas, 11:00 | 3 | 4 | 8 |
| CPU 20%, 6 replicas, 02:00 | 2 | 5 | 2 |
Same inputs, three answers. Which one runs is a single SetPolicy call; the autoscaler’s own code never changes.
Class diagram
The autoscaler depends only on the ScalePolicy interface. Adding a fourth policy means writing one type; the autoscaler doesn’t change.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package scaling
import "math"
type Metrics struct {
CPU float64 // average utilization, 0–100
Hour int // local hour, 0–23
}
// ScalePolicy is the strategy: given the current size and the metrics,
// how many replicas should run?
type ScalePolicy interface {
Desired(current int, m Metrics) int
}
type TargetTracking struct{ Target float64 }
func (p TargetTracking) Desired(current int, m Metrics) int {
return int(math.Ceil(float64(current) * m.CPU / p.Target))
}
type StepScaling struct{}
func (StepScaling) Desired(current int, m Metrics) int {
switch {
case m.CPU > 80:
return current + 2
case m.CPU > 60:
return current + 1
case m.CPU < 30:
return current - 1
}
return current
}
type Scheduled struct{ Peak, OffPeak int }
func (p Scheduled) Desired(_ int, m Metrics) int {
if m.Hour >= 9 && m.Hour < 18 {
return p.Peak
}
return p.OffPeak
}
// Autoscaler is the context: it uses a policy without knowing which one.
type Autoscaler struct {
policy ScalePolicy
min, max int
}
func (a *Autoscaler) SetPolicy(p ScalePolicy) { a.policy = p } // swap at runtime
func (a *Autoscaler) Decide(current int, m Metrics) int {
return max(a.min, min(a.max, a.policy.Desired(current, m)))
}
1
2
3
4
// A strategy doesn't have to be a type. Often it's just a function value.
slices.SortFunc(pods, func(a, b Pod) int {
return cmp.Compare(b.CPU, a.CPU) // the comparison strategy: busiest first
})
Where you’ll find it: slices.SortFunc (the comparison func is the strategy), http.Client.CheckRedirect, kube-scheduler plugins, Python’s sorted(key=…), and scikit-learn estimators.
Traps
Choosing in every caller. The switch that picks a strategy belongs in one place, typically a factory at startup, not spread through the code.
Swapping while running. SetPolicy on one goroutine while another calls Decide is a data race. Guard the field with a mutex or keep it in an atomic.Pointer.
A strategy for two branches that never change. An if is fine. Reach for the pattern when the options grow or come from config.
Observer
Broadcast a change. Let any number of objects subscribe to changes in another, and notify all of them when it changes, without the source knowing who they are.
- Reach for it when: One change should trigger several independent reactions (metering, alerts, audit), and you want to add reactions without editing the source.
- Go idiom: A registry of callbacks, or one buffered channel per subscriber
- Python idiom: A list of callables;
logginghandlers; libraries such as blinker
Delivery mode matters when one subscriber is slow. With synchronous callbacks, Publish returns only after the slowest one finishes, so the publisher waits. With one buffered channel per subscriber, Publish returns at once and each subscriber catches up from its buffer.
Class diagram
The hollow diamond with * is the Gang of Four book’s way of saying the feed holds any number of subscribers it doesn’t own. Under the convention from Part 1, that’s a plain association: a feed isn’t made of its subscribers. The arrow points from the subject to the interface, never to a concrete subscriber.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package events
import "sync"
type Event struct {
Engine string
Status string // "Starting", "Running", "Degraded", ...
}
// StatusFeed is the subject. It knows its subscribers only as functions.
type StatusFeed struct {
mu sync.Mutex
nextID int
subs map[int]func(Event)
}
// Subscribe registers fn and returns a function that unsubscribes it.
func (f *StatusFeed) Subscribe(fn func(Event)) (unsubscribe func()) {
f.mu.Lock()
defer f.mu.Unlock()
if f.subs == nil {
f.subs = map[int]func(Event){}
}
id := f.nextID
f.nextID++
f.subs[id] = fn
return func() {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.subs, id)
}
}
// Publish calls every subscriber. It copies the list first and calls the
// callbacks outside the lock, so a callback may subscribe or unsubscribe
// without deadlocking.
func (f *StatusFeed) Publish(e Event) {
f.mu.Lock()
subs := make([]func(Event), 0, len(f.subs))
for _, fn := range f.subs {
subs = append(subs, fn)
}
f.mu.Unlock()
for _, fn := range subs {
fn(e)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// The channel version: each subscriber gets its own buffered channel,
// so one slow subscriber can't stall the publisher.
type Broadcaster struct {
mu sync.Mutex
subs []chan Event
}
func (b *Broadcaster) Subscribe(buffer int) <-chan Event {
ch := make(chan Event, buffer)
b.mu.Lock()
b.subs = append(b.subs, ch)
b.mu.Unlock()
return ch
}
func (b *Broadcaster) Publish(e Event) (dropped int) {
b.mu.Lock()
defer b.mu.Unlock()
for _, ch := range b.subs {
select {
case ch <- e:
default:
dropped++ // buffer full: drop instead of blocking (a deliberate policy)
}
}
return dropped
}
Where you’ll find it: client-go informers’ AddEventHandler, signal.Notify, SNS, EventBridge and Kafka consumer groups across processes, Python logging handlers, and Django signals.
Traps
A full buffer needs a policy. When a channel subscriber falls behind, decide whether Publish blocks, drops the event (as Broadcaster does), or drops the oldest.
Lapsed listeners. A subscriber that never unsubscribes stays alive forever, along with everything it references. Return an unsubscribe function and defer it.
Relying on order. Go map iteration is random, so subscribers run in a different order each time. If B must run after A, that’s a workflow, not an observer.
Calling back under a lock. If Publish holds the mutex while calling subscribers, one that calls Subscribe deadlocks. Copy the list, unlock, then call, as the code does.
State
Behave by mode. Let an object change its behavior when its internal mode changes, by giving each mode its own object.
- Reach for it when: Every method starts with
switch status, and adding a mode means editing all of them. - Go idiom: One type per state behind an interface, or a transition table for simple machines
- Python idiom: A class per state with refusing defaults; or the
transitionslibrary
The state machine
Start() and Stop() are calls your code makes; done, timeout and crash are events from outside.
Rounded boxes are states, arrows are transitions labeled with what triggers them, and the black dot is where a new instance starts.
| State | Start() | Stop() | Done() | timeout | crash |
|---|---|---|---|---|---|
| Stopped | → Starting | no-op | error: nothing in flight | – | – |
| Starting | no-op | error: cannot stop while starting | → Running | → Failed | – |
| Running | no-op | → Stopping | error: nothing in flight | – | → Failed |
| Stopping | error: wait until stopped | no-op | → Stopped | – | – |
| Failed | → Starting | no-op | error: nothing in flight | – | – |
A dash means the state doesn’t listen for that event.
Class diagram
Same shape as Strategy: the context holds an interface. The difference is the accent arrow. States call back into the instance to choose the next state.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package lifecycle
import "fmt"
// State is one mode of an Instance. Each method decides what that call
// means in this mode, and which state comes next.
type State interface {
Name() string
Start(i *Instance) error
Stop(i *Instance) error
Done(i *Instance) error // the in-flight operation finished
}
type Instance struct{ state State }
func NewInstance() *Instance { return &Instance{state: Stopped{}} }
func (i *Instance) Start() error { return i.state.Start(i) }
func (i *Instance) Stop() error { return i.state.Stop(i) }
func (i *Instance) Done() error { return i.state.Done(i) }
func (i *Instance) State() string { return i.state.Name() }
func (i *Instance) setState(s State) { i.state = s }
type Stopped struct{}
func (Stopped) Name() string { return "Stopped" }
func (Stopped) Start(i *Instance) error { i.setState(Starting{}); return nil }
func (Stopped) Stop(*Instance) error { return nil } // already stopped
func (Stopped) Done(*Instance) error { return fmt.Errorf("nothing in flight") }
type Starting struct{}
func (Starting) Name() string { return "Starting" }
func (Starting) Start(*Instance) error { return nil } // already on its way
func (Starting) Stop(*Instance) error { return fmt.Errorf("cannot stop while starting") }
func (Starting) Done(i *Instance) error { i.setState(Running{}); return nil }
type Running struct{}
func (Running) Name() string { return "Running" }
func (Running) Start(*Instance) error { return nil } // no-op: already running
func (Running) Stop(i *Instance) error { i.setState(Stopping{}); return nil }
func (Running) Done(*Instance) error { return fmt.Errorf("nothing in flight") }
type Stopping struct{}
func (Stopping) Name() string { return "Stopping" }
func (Stopping) Start(*Instance) error { return fmt.Errorf("wait until stopped") }
func (Stopping) Stop(*Instance) error { return nil } // already on its way
func (Stopping) Done(i *Instance) error { i.setState(Stopped{}); return nil }
1
2
3
4
5
6
i := NewInstance()
_ = i.Start() // Stopped → Starting
_ = i.Done() // Starting → Running
_ = i.Start() // Running: no-op
fmt.Println(i.State()) // Running
fmt.Println(i.Stop(), i.Stop(), i.Start()) // <nil> <nil> wait until stopped
Or a transition table
When states carry no data and no logic of their own, a table is shorter and easier to review. It also includes Failed, which the interface version leaves out for brevity.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Table-driven alternative: clearer when states carry no data or logic.
type state string
type event string
var transitions = map[state]map[event]state{
"Stopped": {"start": "Starting"},
"Starting": {"done": "Running", "timeout": "Failed"},
"Running": {"stop": "Stopping", "crash": "Failed"},
"Stopping": {"done": "Stopped"},
"Failed": {"start": "Starting"},
}
func next(s state, e event) (state, error) {
if to, ok := transitions[s][e]; ok {
return to, nil
}
return s, fmt.Errorf("%s: event %q not allowed", s, e)
}
Where you’ll find it: EC2 instance and Kubernetes Pod lifecycles, TCP connection states, http.Server.ConnState, and AWS Step Functions.
Traps
Transitions scattered everywhere. If any code can set status = "Running", the machine is only a suggestion. Only states call setState.
A state with no way out. Every in-progress state needs an exit, including a timeout. A Starting that never hears back must move to Failed, or it waits forever.
Racing events. Two goroutines calling Start and Stop at once can both see Running. Lock the instance, or let one goroutine own it and feed it events through a channel.
Silently ignored calls. Decide per state whether an unexpected call is a no-op or an error, and log it. A swallowed Stop() becomes a support ticket.
Command
Package the request. Turn a request into an object that carries everything needed to run it, so it can be queued, logged, retried, sent elsewhere or undone.
- Reach for it when: You need to queue, schedule, retry, audit or undo operations, or hand them to another process.
- Go idiom: An interface with
Execute(andUndo); afunc()closure for the simplest case - Python idiom: A dataclass per command;
functools.partialfor lightweight ones
Class diagram
The runner holds commands only through the interface. Both commands act on the same receiver, which is the code that actually changes the cluster.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package ops
import (
"context"
"fmt"
)
// Cluster is the receiver: it knows how to do the actual work.
type Cluster struct {
replicas int
cert string
}
func (c *Cluster) Replicas() int { return c.replicas }
func (c *Cluster) SetReplicas(n int) { c.replicas = n }
func (c *Cluster) InstallCert(p string) { c.cert = p }
// Command is a request turned into a value: it can wait in a queue,
// be logged, be retried, or be undone.
type Command interface {
Name() string
Execute(ctx context.Context) error
Undo(ctx context.Context) error
}
type Scale struct {
C *Cluster
To int
from int // remembered by Execute so Undo can put it back
}
func (s *Scale) Name() string { return fmt.Sprintf("scale to %d", s.To) }
func (s *Scale) Execute(context.Context) error {
s.from = s.C.Replicas()
s.C.SetReplicas(s.To)
return nil
}
func (s *Scale) Undo(context.Context) error {
s.C.SetReplicas(s.from)
return nil
}
// Runner is the invoker: it decides when commands run, and keeps history.
type Runner struct {
queue []Command
history []Command
}
func (r *Runner) Submit(c Command) { r.queue = append(r.queue, c) }
func (r *Runner) RunNext(ctx context.Context) error {
if len(r.queue) == 0 {
return nil
}
c := r.queue[0]
r.queue = r.queue[1:]
if err := c.Execute(ctx); err != nil {
return fmt.Errorf("%s: %w", c.Name(), err)
}
r.history = append(r.history, c)
return nil
}
func (r *Runner) Undo(ctx context.Context) error {
if len(r.history) == 0 {
return fmt.Errorf("nothing to undo")
}
c := r.history[len(r.history)-1]
r.history = r.history[:len(r.history)-1]
return c.Undo(ctx)
}
1
2
3
4
5
6
7
8
9
10
c := &Cluster{replicas: 3}
var r Runner
r.Submit(&Scale{C: c, To: 5})
r.Submit(&Scale{C: c, To: 8})
_ = r.RunNext(ctx) // 3 → 5
_ = r.RunNext(ctx) // 5 → 8
_ = r.Undo(ctx) // back to 5
fmt.Println(c.Replicas()) // 5
Where you’ll find it: exec.Cmd (configure now, Run later), database migrations (up and down), terraform plan -out then apply, and SQS messages or Celery tasks run by another process.
Traps
Undo that can’t undo. An email can’t be unsent and a deleted volume can’t be undeleted. Mark those commands irreversible, or give them a compensating action.
Capturing references, not values. A command holding a pointer to a mutable request sees later edits. Copy what it needs when it’s created.
Retries without idempotency. A worker that crashes after Execute but before acknowledging runs the command twice. Give each command an ID and make Execute safe to repeat.
Undo after someone else’s change. Restoring 3 replicas after another operator scaled to 10 silently clobbers their change. Check the current value first, or refuse.
Chain of Responsibility
Pass it along. Give a request to a line of handlers. Each one either handles it or passes it to the next, so the sender doesn’t need to know who will take it.
- Reach for it when: Several handlers might apply, in a known order of preference: authentication methods, credential sources, routing rules, approval levels.
- Go idiom: A slice of handlers tried in order; or a
nextfield on each link - Python idiom: A list of callables, each returning
Noneto pass
Where each credential stops
Different credentials sent through an API gateway’s authentication chain of client certificate, then service account token, then OIDC:
| Credential | Where it stops | Result |
|---|---|---|
| client certificate | x509, the first link | ci-runner |
| service account token | ServiceAccount, after x509 passes | system:serviceaccount:ci:deployer |
| OIDC id_token | OIDC, after two passes | alice@example.com |
| expired service account token | ServiceAccount: mine, but expired | 401; the chain stops here |
| no credentials | falls off the end | 401, ErrUnauthorized |
Class diagram
The classic shape: every link implements the interface and holds the next one. The accent arrow is that next field, pointing back at the interface.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package auth
import (
"errors"
"net/http"
"strings"
)
type User struct{ Name string }
// Authenticator is one link. ok=false means "not mine, ask the next one".
// An error means "mine, but invalid".
type Authenticator interface {
Authenticate(r *http.Request) (u User, ok bool, err error)
}
// Chain tries each link in order; the first that says ok wins.
// Note that Chain is itself an Authenticator, so chains nest.
type Chain []Authenticator
var ErrUnauthorized = errors.New("unauthorized")
func (c Chain) Authenticate(r *http.Request) (User, bool, error) {
for _, a := range c {
u, ok, err := a.Authenticate(r)
if err != nil {
return User{}, false, err // recognized but invalid: stop here
}
if ok {
return u, true, nil
}
}
return User{}, false, ErrUnauthorized // fell off the end
}
type ClientCert struct{}
func (ClientCert) Authenticate(r *http.Request) (User, bool, error) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
return User{}, false, nil // not mine
}
return User{Name: r.TLS.PeerCertificates[0].Subject.CommonName}, true, nil
}
type BearerToken struct {
// Verify reports ok=false for tokens it doesn't recognize (another issuer)
// and an error for tokens it recognizes but rejects (expired, bad signature).
Verify func(token string) (u User, ok bool, err error)
}
func (b BearerToken) Authenticate(r *http.Request) (User, bool, error) {
token, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !found {
return User{}, false, nil // no bearer token: not mine
}
return b.Verify(token)
}
1
2
3
4
5
6
7
8
9
10
11
authn := Chain{
ClientCert{},
BearerToken{Verify: serviceAccounts.Verify},
BearerToken{Verify: oidcIssuer.Verify},
}
u, ok, err := authn.Authenticate(r)
if err != nil || !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
Where you’ll find it: the AWS SDK default credential chain (environment, config files, web identity, container, then instance metadata; boto3 works the same way), kube-apiserver authentication, exceptions climbing the call stack, and Python logging propagation.
Traps
Falling off the end. Always end the chain with a decision, a rejection or an anonymous user, so nothing is silently dropped.
Order is policy. Put cheap and specific handlers first. Moving OIDC ahead of client certificates changes who gets authenticated as what.
What “mine, but invalid” means. This chain stops with 401 on an expired token. kube-apiserver’s union authenticator keeps trying the remaining authenticators by default and reports the combined errors. Both are reasonable; choose on purpose.
Who handled it? Log the handler’s name with the result, or debugging means reading every link.
Five specialists
You’ll mostly use these rather than write them. Each gets the code and the one thing to know.
Template Method
Write the algorithm once, in a fixed order, and let subclasses (or, in Go, the type you pass in) fill in the steps.
- Where you’ll find it:
sort.Sortandheap.Initwith their interfaces;unittest.TestCase(setUp, the test,tearDown);threading.Thread.run.
1
2
3
4
5
6
7
8
9
// sort.Sort is a template method: the algorithm is fixed inside the
// standard library, and you supply the three steps it calls.
type byCPU []Pod
func (p byCPU) Len() int { return len(p) }
func (p byCPU) Less(i, j int) bool { return p[i].CPU > p[j].CPU }
func (p byCPU) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
sort.Sort(byCPU(pods)) // busiest first
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from abc import ABC, abstractmethod
class Reconciler(ABC):
def run(self) -> None: # the template method: a fixed order
want, have = self.desired(), self.actual()
for change in self.diff(want, have):
self.apply(change)
self.report(len(want))
@abstractmethod
def desired(self) -> set: ... # steps each subclass must fill in
@abstractmethod
def actual(self) -> set: ...
@abstractmethod
def apply(self, change) -> None: ...
def diff(self, want: set, have: set): # a default step, overridable
return [("create", x) for x in sorted(want - have)] + \
[("delete", x) for x in sorted(have - want)]
def report(self, n: int) -> None: # a hook: does nothing unless overridden
pass
Template Method or Strategy? Template Method varies one step inside a fixed skeleton, usually through inheritance. Strategy swaps the whole algorithm through composition. In Go you’ll mostly write the Strategy shape, because there’s no inheritance to lean on.
Iterator
Walk through a collection one item at a time without knowing how it’s stored or fetched.
- Where you’ll find it: Go 1.23 range-over-func with
iter.Seq;bufio.Scanner;sql.Rows.Next; AWS SDK paginators; every Pythonforloop and generator.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// AllPods hides pagination behind an iterator (Go 1.23 range-over-func).
func AllPods(ctx context.Context, api PodLister) iter.Seq2[Pod, error] {
return func(yield func(Pod, error) bool) {
token := ""
for {
page, next, err := api.ListPods(ctx, token)
if err != nil {
yield(Pod{}, err)
return
}
for _, p := range page {
if !yield(p, nil) {
return // the caller broke out of the loop
}
}
if next == "" {
return
}
token = next
}
}
}
for pod, err := range AllPods(ctx, api) {
if err != nil {
return err
}
fmt.Println(pod.Name)
}
1
2
3
4
5
6
7
8
9
10
11
def all_pods(api):
"""A generator hides pagination: callers just loop."""
token = None
while True:
page, token = api.list_pods(token)
yield from page
if token is None:
return
for pod in all_pods(api):
print(pod["name"])
Stopping early must clean up. In Go, return as soon as yield returns false so pages, rows or connections are released. In Python, put cleanup in a finally block; it runs when the generator is closed.
Mediator
Stop components from talking to each other directly. They report to one object, and it decides what happens next.
- Where you’ll find it: Kubernetes controllers never call each other; they coordinate through objects in the API server. A CI pipeline definition decides that tests run after the build, so the build step doesn’t have to.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Pipeline is the mediator: components report to it, and only it
// decides what happens next. Components never call each other.
type Pipeline struct {
test *Tester
deploy *Deployer
notify *Notifier
}
func (p *Pipeline) Notify(from, event string) {
switch {
case event == "failed":
p.notify.Send(from + " failed")
case from == "build":
p.test.Run()
case from == "test":
p.deploy.Rollout()
case from == "deploy":
p.notify.Send("released")
}
}
// A component only knows the mediator:
func (b *Builder) Finish(ok bool) {
if ok {
b.pipeline.Notify("build", "ok")
} else {
b.pipeline.Notify("build", "failed")
}
}
Changing the order of steps now means editing one object instead of four. The mediator can become a god object, so keep only coordination in it; the real work stays in the components.
Memento
Capture an object’s state as an opaque snapshot, so it can be restored later without exposing its internals.
- Where you’ll find it:
SAVEPOINTandROLLBACK TO SAVEPOINTin SQL;etcdctl snapshot saveandsnapshot restore; EBS and VM snapshots; editor undo history.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package config
// Snapshot is the memento. Its fields are unexported, so code outside this
// package can store and hand back a Snapshot but can't read or change it.
type Snapshot struct {
replicas int
image string
}
type Config struct {
replicas int
image string
}
func (c *Config) Save() Snapshot { return Snapshot{c.replicas, c.image} }
func (c *Config) Restore(s Snapshot) { c.replicas, c.image = s.replicas, s.image }
func (c *Config) SetImage(img string) { c.image = img }
1
2
3
history := []config.Snapshot{cfg.Save()} // the caretaker holds snapshots, never looks inside
cfg.SetImage("api:v3") // a risky change
cfg.Restore(history[len(history)-1]) // roll back
Snapshots cost memory, and shallow copies share data. Keep a bounded history or store diffs, and copy maps, slices and pointers deeply, or the snapshot changes along with the live object.
Visitor
Add new operations over a fixed set of types without changing those types.
- Where you’ll find it:
go/ast.Walkwith anast.Visitor(andast.Inspect, its function form); Python’sast.NodeVisitor; linters and formatters walking syntax trees.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Visitor interface {
VisitPod(*Pod)
VisitService(*Service)
}
type Resource interface{ Accept(Visitor) }
type Pod struct {
Name string
CPU int // millicores
}
type Service struct {
Name string
LoadBalancer bool
}
func (p *Pod) Accept(v Visitor) { v.VisitPod(p) } // double dispatch: the element
func (s *Service) Accept(v Visitor) { v.VisitService(s) } // picks the visitor method
// A new operation is a new visitor; Pod and Service don't change.
type CostVisitor struct{ Monthly float64 }
func (c *CostVisitor) VisitPod(p *Pod) { c.Monthly += float64(p.CPU) / 1000 * 25 } // illustrative rate
func (c *CostVisitor) VisitService(s *Service) {
if s.LoadBalancer {
c.Monthly += 18 // illustrative rate
}
}
A type switch is often simpler. Visitor makes adding an operation cheap (one new visitor) and adding a type expensive (every visitor changes). A type switch is the opposite, so reach for Visitor only when the types are fixed and the operations keep growing.
Check your understanding
-
1
The autoscaler should switch between target tracking and a fixed schedule without any change to its own code.
Show answer
Answer Strategy
Several ways to do one job, picked by the caller or config and swappable at runtime with
SetPolicy. -
2
When a compute engine’s status changes, metering, alerts and the audit log all need to react.
Show answer
Answer Observer
One change, many independent reactions, and the source shouldn’t know who’s listening.
-
3
Start()should launch nodes when stopped, do nothing when running, and refuse while stopping.Show answer
Answer State
The same call behaves differently in each mode, and the object moves itself between modes.
-
4
Queue cluster operations, run them on a worker, and let an operator undo the last one.
Show answer
Answer Command
Requests become values that can wait, run later, and carry what they need to undo themselves.
-
5
Try a client certificate, then a bearer token, then OIDC. The first that works wins.
Show answer
Answer Chain
Handlers in a fixed order of preference, each taking the request or passing it on.
-
6
sort.Sort(data)runs a fixed algorithm and calls yourLen,LessandSwap.Show answer
Answer Template Method
The skeleton is fixed in the library, and you fill in the steps it calls.
-
7
for pod, err := range AllPods(ctx, api)hides three pages of API calls.Show answer
Answer Iterator
Walk a collection one item at a time without knowing how it’s stored or fetched.
-
8
Build, test, deploy and notify steps stop calling each other and report to one pipeline object instead.
Show answer
Answer Mediator
A central coordinator holds the who-reacts-to-what logic, so each component only knows the hub.
-
9
Snapshot an object before a risky change and roll back, without exposing its fields.
Show answer
Answer Memento
An opaque saved state that the owner can restore and nobody else can read. In Go, unexported fields enforce that.
-
10
Add a cost report and a lint check across Pod, Service and Ingress without touching those types.
Show answer
Answer Visitor
New operations over a fixed set of types: each operation becomes a visitor.
-
11
TargetTracking{Target: 60}sees 84% CPU on 4 replicas, and the autoscaler clamps to 2–20. How many replicas does it ask for?Show answer
Answer 6
ceil(4 × 84 / 60)isceil(5.6), which is 6, inside the clamp.StepScalinghappens to agree (84% is above 80%, so 4 + 2), whileScheduledat 22:00 says 2. -
12
One goroutine calls
SetPolicywhile another is insideDecide. What is that, and what fixes it?Show answer
Answer A data race; guard the field with a mutex or keep it in an
atomic.PointerAn interface value is two words, and an unsynchronized write next to a read is a data race even if it usually works. Swapping strategies at runtime needs atomic access.
-
13
Why does
StatusFeed.Publishcopy the subscriber list and call the callbacks after unlocking?Show answer
Answer So a callback can subscribe or unsubscribe without deadlocking
Subscribetakes the same mutex. Holding it while calling subscribers means one that callsSubscribewaits on itself forever. Copy, unlock, then call. -
14
Subscriber B must always run after subscriber A. What does the post recommend?
Show answer
Answer Treat it as a workflow, not an observer: call them in order explicitly
The Go feed stores subscribers in a map, and map iteration order is random. Observers should be independent; a required order is a sequence, which belongs in code that calls the steps in order.
-
15
On a Running instance from the State chapter, what does
fmt.Println(i.Stop(), i.Stop(), i.Start())print?Show answer
Answer
<nil> <nil> wait until stoppedThe first
Stop()moves Running to Stopping. A secondStop()while Stopping is a no-op.Start()while Stopping is refused with “wait until stopped”. -
16
A worker crashes after a command’s
Executesucceeded but before it acknowledged the message. What happens, and what protects you?Show answer
Answer It runs again; give each command an ID and make
Executesafe to repeatAt-least-once delivery means a retry after a crash. Idempotent commands turn a duplicate run into a no-op.
-
17
In this post’s authentication chain, an expired service account token arrives. What happens?
Show answer
Answer The ServiceAccount link recognizes it as its own but invalid, so the chain stops with a 401
This chain treats “mine, but invalid” as final. kube-apiserver’s union authenticator instead keeps trying the remaining authenticators by default and reports the combined errors. Both are reasonable; choose on purpose.
-
18
Strategy and State both hold an interface and delegate to it. Which question tells them apart?
Show answer
Answer Who flips the switch: the caller, or the object itself?
In Strategy the caller picks, and strategies never mention each other. In State each state knows which one comes next and sets it on the context.
-
19
When does Visitor pay off over a type switch?
Show answer
Answer When the set of types is fixed and the operations keep growing
Visitor makes adding an operation cheap, one new visitor, and adding a type expensive, since every visitor must learn it. A type switch is the opposite.
Part 6: Field guide: which pattern, and when?
Everything from Parts 3 to 5, arranged for the moment you have a real problem: find the problem, get a pattern, rule out its lookalike, and check whether something simpler would do. All 23 Gang of Four patterns are here, plus the simple factory.
The map
| Bucket | The question | Write often | Use sometimes | Rarely build |
|---|---|---|---|---|
| Creational · how objects are born | Who gets to call new? | Singleton, Factory, Builder | Factory Method, Abstract Factory, Prototype | – |
| Structural · how objects are wired | What’s standing in the middle? | Adapter, Decorator, Proxy, Facade, Composite | Bridge | Flyweight |
| Behavioral · how objects decide | Who decides what happens next? | Strategy, Observer, State, Command, Chain of Responsibility | Template Method, Iterator, Mediator | Memento, Visitor, Interpreter |
Pattern finder
Find the situation closest to yours.
Creating objects
| Situation | Pattern | Why |
|---|---|---|
| Every handler opens its own database pool or client | Singleton | You need one shared instance per process. Create it once, in main or behind sync.Once, and share it. |
| The concrete type depends on config: s3, gcs or local | Factory | One function maps the config value to a type and returns the interface. Callers never name the concrete type. |
| The same switch on type is copied into several places | Factory | Move the switch into one function. A registry lets new types add themselves. |
| A constructor takes eight arguments, most of them optional | Builder | Name each setting and default the rest. In Go, functional options are the usual form. |
| Some field combinations are invalid and must be rejected | Builder | Build() is the one place to validate across fields before the object exists. |
| Several resources must all come from the same cloud or backend | Abstract Factory | Choosing one factory chooses a whole matching family, so mixed providers can’t happen. |
| A shared workflow differs only in what it creates | Factory Method | Keep the workflow in one place and inject, or override, only the creation step. |
| I must change an object that came from a shared cache | Prototype | Clone it first (DeepCopy(), copy.deepcopy) so other readers of the cache aren’t affected. |
Connecting and wrapping
| Situation | Pattern | Why |
|---|---|---|
| A vendor SDK doesn’t match the interface my code uses | Adapter | A thin type translates your calls into the SDK’s and keeps vendor types out of your code. |
| Add logging, metrics or retries around existing calls | Decorator | A wrapper with the same interface adds the behavior and stacks with other wrappers. |
| Run the same code around every HTTP handler or RPC | Decorator | That’s middleware: func(http.Handler) http.Handler, or gRPC interceptors. |
| A dependency is slow or remote and asked the same thing repeatedly | Proxy | A caching proxy answers repeats itself and calls through only on a miss. |
| Don’t create or connect until something is actually needed | Proxy | A virtual proxy builds the real object on first use, with sync.Once inside. |
| Check permissions before calls reach the real object | Proxy | A protection proxy refuses the call before forwarding anything. |
| Callers repeat the same multi-step sequence across several services | Facade | Write the sequence once behind one method, including what happens when a step fails. |
| The same call must work on one item or a whole tree of them | Composite | Groups implement the item’s interface and forward to their children. |
Type names glue two ideas together: IncidentSlack, DigestEmail |
Bridge | Split the two axes into two hierarchies and join them with a field. |
| Millions of objects carry the same data | Flyweight | Share one immutable copy per distinct value and keep only the differences per object. |
Deciding and reacting
| Situation | Pattern | Why |
|---|---|---|
| Several algorithms for one job, picked by config | Strategy | Put each algorithm behind one small interface or func, and let config choose. |
| Several parts of the system must react when something changes | Observer | Publish the change. Each part subscribes and decides what it means for it. |
Every method starts with switch status |
State | Give each status its own type, or table row, so the behavior for each mode lives together. |
| Some calls are only legal in some lifecycle states | State | Each state decides what a call means and which state comes next. Illegal calls get a clear error. |
| Operations should wait in a queue and run on a worker | Command | Make each operation a value with Execute. The queue and the worker don’t need to know what it does. |
| Undo the last operation | Command | Each command records what it needs to reverse itself. Also consider Memento if you need to restore a whole object’s state rather than reverse one step. |
| Audit or replay every change | Command | Commands are values you can log, store and run again. |
| Try several handlers in order; the first that can, does | Chain of Responsibility | Each handler takes the request or passes it on, and the order is explicit. |
| Fall back across credential sources or auth methods | Chain of Responsibility | This is how the AWS credential chain and kube-apiserver authentication work. |
| Variants share the order of steps and differ in a few of them | Template Method | Fix the skeleton once. Let each variant supply its own steps. |
| Callers shouldn’t have to deal with pagination or streaming | Iterator | Hide the paging behind an iterator or generator. Callers just loop. |
| Components call each other in a tangle | Mediator | They report to one hub, and the hub owns the who-does-what-next logic. |
| Snapshot state before a risky change and roll back if needed | Memento | Save an opaque snapshot, and restore it if the change goes wrong. |
| Add new operations over a fixed set of types | Visitor | Each operation becomes a visitor. The types only accept it. |
| Users write small rules or filters that must be evaluated | Interpreter | Parse them into a tree of nodes that evaluate themselves. Better still, embed CEL or JSONPath. |
Choosing in a bucket
Pick the bucket, and its tree narrows it to one pattern in one or two questions. An editable version of all three is in design-patterns-decision-map.drawio, which opens in draw.io.
Creating objects
Factory, Factory Method and Abstract Factory all answer “which type?”. They differ in who decides: the input, the subclass, or the family.
Connecting objects
Two questions sort the five common wrappers. Bridge and Flyweight aren’t wrappers, so they sit outside the tree.
Deciding behavior
Four kinds of decision, seven patterns. Interpreter belongs with the specialists: it evaluates a small language users write.
Spot it in UML
Most patterns leave a recognizable shape in a class diagram or in Go struct definitions, so you can name them in an unfamiliar codebase from the types alone.
Implements I and holds one I
Decorator if it always forwards and adds behavior. Proxy if it may answer, refuse or delay. Chain of Responsibility has this shape too, when the field is next and a link may stop.
Implements I and holds many I
Pattern: Composite
The aggregation arrow back to its own interface, with *, is the giveaway.
Holds an interface the caller can swap
Pattern: Strategy
The context only delegates. The implementations never mention each other.
Same shape, but implementations set the next one
Pattern: State
The back-arrow from a state to the context is what separates State from Strategy.
Implements your interface, holds a foreign type
Pattern: Adapter
Two different types on either side. If both sides share an interface, it’s not an adapter.
Holds several different types, offers fewer methods
Pattern: Facade
Callers depend on one type instead of four. The subsystems stay reachable.
A function returns an interface and creates concrete types
Pattern: Factory
The «create» arrows fan out from one place. Callers see only the interface.
Holds a list of listeners and loops over them
Pattern: Observer
The subject points at the interface, never at a concrete subscriber.
Package-level instance behind a once
Pattern: Singleton
In Go there’s no static keyword. Underlined members are package-level variables.
Where they live in one service
A control-plane service that provisions and scales compute for customers, with the pattern at each component:
| Where | Pattern | Why it fits there |
|---|---|---|
| API gateway | Chain of Responsibility | Authenticators tried in order: client certificate, service account token, OIDC. |
| API gateway | Decorator | Logging, recovery and tracing wrapped around every handler. |
| Handlers | Builder | Turn a request into a validated Spec before anything runs. |
| Handlers → job queue | Command | The operation becomes a value that can wait, be retried, audited and undone. |
| Worker | State | The engine lifecycle allows only legal transitions. |
| Worker | Strategy | The scaling policy comes from config and can change at runtime. |
| Catalog client | Proxy | Caches lookups and dials the backend only on first use. |
| Status feed | Observer | Metering, alerts and the audit log react to status changes independently. |
| Notifier | Adapter | Slack or PagerDuty behind one Notify call. |
| Site provisioner | Facade | One Provision call over network, compute, DNS and registry, with rollback. |
| Cloud factory | Abstract Factory | AWS or Azure for the whole site, never mixed. |
| Cloud SDK clients | Adapter | Each product hides its SDK behind your own interface. |
| main() | Singleton | One config and one database pool, created once and passed down. |
| main() | Factory | NewStore(kind) picks the storage backend from config. |
| main() | Bridge | slog.New(handler): the logging API and the output format vary independently. |
| Paged API reads | Iterator | Callers range over all pods; the pages stay hidden. |
Lookalikes
Most wrong picks come from a lookalike with the same shape. Each pair is separated by one question.
| Pair | Ask | If yes | If no |
|---|---|---|---|
| Factory vs Strategy | Is it choosing what to create, once? | Factory: picks what to create | Strategy: picks how to do a job, and can be swapped later |
| Factory Method vs Template Method | Is the step that varies the creation of an object? | Factory Method: the varying step creates the product | Template Method: the varying steps do work |
| Abstract Factory vs Builder | Are several different objects made that must match? | Abstract Factory: a matching set, all at once | Builder: one complex object, step by step |
| Prototype vs Memento | Will the copy be used as a new object? | Prototype: the copy becomes a new object | Memento: the copy restores the original later |
| Singleton vs Flyweight | Is there exactly one instance of the type? | Singleton: one instance, process-wide | Flyweight: one shared instance per distinct value |
| Adapter vs Facade | Does it wrap one object to fit an interface that already exists? | Adapter: one object, existing interface | Facade: many objects, a new smaller interface |
| Adapter vs Bridge | Are you retrofitting two pieces that already exist? | Adapter: retrofitted after the fact | Bridge: designed up front for two axes |
| Decorator vs Proxy | Does it always forward the call and add behavior? | Decorator: always forwards, adds behavior | Proxy: may answer, refuse or delay |
| Decorator vs Chain of Responsibility | Does every call reach the wrapped object? | Decorator: always forwards | Chain of Responsibility: a link may handle it and stop |
| Decorator vs Composite | Does it wrap exactly one object of its interface? | Decorator: wraps one | Composite: holds many children |
| Strategy vs State | Does the caller pick the implementation? | Strategy: the caller picks | State: the states pick the next state |
| Observer vs Mediator | Does each listener decide for itself how to react? | Observer: listeners decide | Mediator: the hub decides who does what |
| Observer vs Chain of Responsibility | Should every receiver get it? | Observer: everyone hears it | Chain of Responsibility: the first able handler takes it |
| Command vs Strategy | Is it what to do, to run later or undo? | Command: what to do, possibly later | Strategy: how to do it, now |
| Facade vs Mediator | Do calls only flow inward, from callers to subsystems? | Facade: callers call it | Mediator: components report to it, and it calls them |
Rules of thumb
- Name the decision before the pattern. Ask which bucket question you’re answering: who creates it, what sits in the middle, or who decides. The pattern follows from the decision.
- Wait for the second case. A pattern with one implementation is indirection without a payoff. Add the factory, strategy or adapter when the second type, policy or vendor shows up.
- In Go, reach for the smallest shape. A func before a one-method interface, a small interface before a type hierarchy, and define interfaces where they’re used. Most Go patterns are a few lines.
- Concurrency changes the details. Singletons need
sync.Once, observers need a policy for slow subscribers, strategies swapped at runtime need atomic access, and state machines need one owner or a lock.
Cheat sheet
“Try first” is what to write before reaching for the pattern; reach for it only when that stops being enough.
| Pattern | Question it answers | Try first | Go shape |
|---|---|---|---|
| Singleton | How many? | Create it once in main and pass it down |
var getConfig = sync.OnceValue(loadConfig) |
| Factory | Which type? | The constructor, until a second implementation exists | func NewStore(kind, target string) (Store, error) |
| Factory Method | Which type, per variant? | A constructor func field, which is the whole pattern in Go |
type Exporter struct{ newSink func() Sink } |
| Abstract Factory | Which family? | A simple factory, if there’s only one kind of product | type CloudFactory interface{ NewVM(size string) VM; NewBucket(name string) Bucket } |
| Builder | How assembled? | A struct literal, keyword arguments or functional options | pod.New("api").Image("nginx:1.27").Port(8080).Build() |
| Prototype | Copy an existing one? | Building a fresh one, if that’s cheap | p := cached.DeepCopy(); p.Labels["team"] = "infra" |
| Adapter | The calls don’t fit? | Changing one side, if you own both | type SlackNotifier struct{ client *slack.Client } // implements Notifier |
| Decorator | Add behavior around calls? | Inline code, if one call site needs it | func Logging(next http.Handler) http.Handler |
| Proxy | Should the call get through? | A direct call, if it’s cheap, local and unrestricted | type CachingCatalog struct{ real Catalog; cache map[string]Item } // implements Catalog |
| Facade | Too many parts to learn? | Leaving the sequence with its one caller until it repeats | func (p *SiteProvisioner) Provision(ctx context.Context, s Spec) (Site, error) |
| Composite | One thing or many? | A slice and a loop, if nesting is one level deep | type Group struct{ children []Resource } // implements Resource |
| Bridge | Two things varying at once? | One hierarchy, until the second axis really varies | type Incident struct{ sender Sender } |
| Flyweight | Millions of near-identical objects? | Plain values, until a profile shows the duplication | h := unique.Make(labels) // Go 1.23+ |
| Strategy | How should the work be done? | An if, for two options that never change |
type ScalePolicy interface{ Desired(cur int, m Metrics) int } |
| Observer | Who needs to know? | A direct call, for one reaction you own | unsubscribe := feed.Subscribe(func(e Event) { ... }) |
| State | What does this call mean right now? | An enum and a switch, for a few states with little logic |
func (Running) Stop(i *Instance) error { i.setState(Stopping{}); return nil } |
| Command | When, and how many times? | A plain call or closure, if it runs once, now | type Command interface{ Execute(ctx context.Context) error; Undo(ctx context.Context) error } |
| Chain of Responsibility | Who handles this? | An if/else, for two fixed checks |
type Chain []Authenticator // tries each in order until one says ok |
| Template Method | Same steps, different details? | Steps as func parameters or a small interface |
sort.Sort(byCPU(pods)) // you supply Len, Less, Swap |
| Iterator | Walk it without knowing its insides? | Returning a slice, if it’s small and in memory | for pod, err := range AllPods(ctx, api) { ... } // iter.Seq2 |
| Mediator | Who talks to whom? | Direct calls, for a few components with a simple flow | func (p *Pipeline) Notify(from, event string) |
| Memento | Can I roll back? | Re-reading the source of truth, such as the database or API server | snap := cfg.Save(); /* risky change */; cfg.Restore(snap) |
| Visitor | New operations over the same types? | A type switch, if types change more often than operations | ast.Walk(v, file) // calls v.Visit(node) for every node |
| Interpreter | Do users write little rules? | Embedding CEL, JSONPath or Rego, or a config struct | sel, _ := labels.Parse("app=api,tier!=db"); sel.Matches(labels.Set(pod.Labels)) |
Prototype and Interpreter
The two patterns not covered earlier.
Prototype
Copy an existing one. Create new objects by cloning a configured original, then change only what differs. Use it when configuring from scratch is costly, or when you must not mutate a shared original, such as an object from a Kubernetes informer cache.
1
2
p := cached.DeepCopy() // edit a copy, never the object other cache readers share
p.Labels["team"] = "infra"
Also http.Request.Clone(ctx) and tls.Config.Clone(); in Python, copy.deepcopy or dataclasses.replace. The trap is a shallow copy, which shares maps, slices and pointers with the original.
Interpreter
Evaluate a little language. Represent a small language as a tree of objects that evaluate themselves. Regular expressions, text/template, Kubernetes label selectors, CEL and PromQL are all interpreters.
1
2
sel, _ := labels.Parse("app=api,tier!=db") // parse once into a tree
sel.Matches(labels.Set(pod.Labels)) // evaluate it per object
Before building one, embed an existing language (CEL, JSONPath, Rego) or use a config struct.
Check your understanding
-
1
Before editing a Pod from an informer cache, you call
pod.DeepCopy().Show answer
Answer Prototype
You clone a configured object and change the copy, so the shared original that other readers of the cache see stays untouched.
-
2
The AWS SDK tries environment variables, then config files, then instance metadata.
Show answer
Answer Chain of Responsibility
Sources in a fixed order; the first one with credentials wins and the rest are never asked.
-
3
otelhttp.NewTransport(http.DefaultTransport)Show answer
Answer Decorator
A
RoundTripperwrapping aRoundTripper, always forwarding and adding tracing spans. -
4
labels.Parse("app=api,tier!=db")returns a selector you run against objects.Show answer
Answer Interpreter
A tiny language parsed into a tree that evaluates itself against each object’s labels.
-
5
terraform plan -out=tfplan, thenterraform apply tfplanlater.Show answer
Answer Command
The change set is saved as a value and executed later, exactly as planned.
-
6
Stopping an EC2 instance is legal only while it’s running.
Show answer
Answer State
What a call means depends on the current mode, and the instance moves between modes itself.
-
7
Informers let several controllers react to Pod changes through
AddEventHandler.Show answer
Answer Observer
Every registered handler hears every change, and the informer doesn’t know who they are.
-
8
io.MultiWriter(file, os.Stdout)is itself anio.Writer.Show answer
Answer Composite
A writer made of writers. Callers can’t tell one from many. A decorator would wrap exactly one.
-
9
sql.Open("pgx", dsn)picks a driver that registered itself by name.Show answer
Answer Factory
A registry factory: the name maps to a constructor, and you get back the common interface.
-
10
grpc.NewClient(target, grpc.WithTransportCredentials(creds))Show answer
Answer Builder
Functional options: Go’s usual way to assemble an object from named, optional settings.
-
11
prometheus.MustRegisterpanics if the same metric is registered twice.Show answer
Answer Singleton
There’s one default registry for the whole process, and it already holds that metric.
-
12
Pipeline steps report “build done” to a hub, and the hub decides that tests run next.
Show answer
Answer Mediator
With Observer, each listener decides how to react. Here the hub decides who does what next, and calls flow both ways: components report to it, and it calls them.
-
13
You copy a config before a risky change, so you can put the same object back if the change goes wrong.
Show answer
Answer Memento
The lookalike question: will the copy be used as a new object? No, it restores the original later, so it’s a memento. A prototype’s copy becomes a new object.
-
14
A metrics library keeps one shared, immutable
Labelsvalue per distinct label set.Show answer
Answer Flyweight
One shared instance per distinct value is Flyweight. Singleton is exactly one instance of a type.
-
15
Your service has exactly one storage backend today. Should you put a factory in front of it?
Show answer
Answer Not yet: call its constructor, and add the factory when the second backend arrives
Wait for the second case. A pattern with one implementation is indirection without a payoff. Tests can already pass a fake through an interface without a factory.