What is software architecture?
Tuesday, 10:40: changing free cancellation from 24 to 48 hours took six days and eleven files, and the admin panel still says 24. Thirty-seven guests were refused a refund they were owed.
Architecture isn't boxes on a whiteboard. It's the handful of choices that would hurt to undo later.
- Problem
A booking app grows from 1 to 14 engineers. Every change starts touching everything, and nobody remembers why things are the way they are.
- Why the simple system fails
Without deliberate structure, business rules leak into controllers, SQL leaks into templates, and big decisions get made in chat threads and forgotten.
- Engineering concept
Architecture = the important, hard-to-change decisions: boundaries, dependencies, data ownership, and how parts communicate. Decide them on purpose and write them down.
- Technology
Layered and hexagonal (ports & adapters) structures, module boundaries, Architecture Decision Records.
- Trade-offs
Structure costs indirection and ceremony. Too little and change becomes expensive; too much and simple features take a week.
The problem
2 min- Maya · Product managerFree cancellation went from 24 to 48 hours. It took six days, and the admin panel still says 24.
- Omar · On-call SRE37 guests were refused refunds they were owed.
- You · Engineer at StayHubIt's one number. How can that take six days?
- Lina · Senior engineer, your mentorGuess first. Then we'll search the code together.
The big idea
4 minis the choices that are expensive to change later: where code and data live, and who may depend on whom. They set the price of every future change.
Not boxes on a whiteboard. In a hotel, repainting a room takes an afternoon; moving the elevator shaft is a construction project. Architecture is the elevator shafts: the few things everything else leans on.
How it works
5 min- 1Year 1: one file
Two engineers, one controller checks "24 hours". Everything fits in two heads.
- 2Years 2–3: copies
The admin panel, a refund job and the PDF each need the rule. Each team copies it.
- 3Year 4: drift
14 engineers. A change misses one copy, and screens disagree. Guests notice.
- 4Fix: one home
A
CancellationPolicyin the core, called by every entry point. That's high : the things inside one part belong together. - 5Fix: loose edges
The core asks for refunds through a port (an interface it owns: "I need refunds"); the payment SDK sits in an adapter that plugs into it. That's low : one part needn't know or change with another.
A useful sorting question: one-way door or two-way door? A date library is two-way: swap it in an hour. The main database, a public API 1,800 hotels integrate with, or who owns which table are one-way: slow down and write the decision down.
Watch a decision being made
4 minLina gives the cancellation rule one home
The rule exists in 11 places and just drifted. Make the next change a one-file change, without a rewrite and without stopping feature work.
- 1Find every copy, name one owner
Search for
24,freeCancel,refundable; list each hit and its entry point. Owner: the Booking area. Templates and the PDF only display the result.Why: You can't consolidate what you haven't found, and a rule with two owners drifts again. Ownership is the architecture decision; the code follows it.
- 2Give it one pure home
Write the rule once, with no HTTP or SDK inside.
ts// core/booking/cancellation-policy.ts export const FREE_CANCEL_HOURS = 48; export function canCancelFree(b: Booking, now: Date): boolean { return hoursBetween(now, b.checkIn) >= FREE_CANCEL_HOURS; }Why: Pure code is testable in milliseconds and callable from the API, admin panel and jobs.
- 3Point every entry point at it; payments behind a port
Replace the 11 copies with calls; templates receive
freeCancelUntilas data. The core declaresPaymentGateway; the SDK lives in an adapter.Why: Until the last copy is gone, two answers are still possible. The port keeps vendor details out of the rule, and tests can use a fake.
- 4Lock the boundary
A lint rule: only
adapters/paymentsimports the payment SDK; no cancellation logic outsidecore/booking. A short decision record says why.Why: Unchecked boundaries erode within months. The record tells the next engineer it was on purpose.
In real life
3 minShopify: one big app, strong modules inside
- Has publicly described keeping its core as one large Rails codebase
- Split into components with declared boundaries, checked by tooling
Fits when you want boundaries without running many systems.
Amazon: many services, each owned by a small team
- Has publicly described services that talk only through APIs
- Each team owns, deploys and runs its own; needs strong tooling and on-call
Fits many teams that must release independently.
What goes wrong
3 minArchitecture lives in what imports what and who owns which data.
Check the real dependency graph; enforce boundaries with tooling.
The next change misses one copy, and guests get different answers.
One home per rule; screens only display the result.
Reversible choices don't deserve committee time.
Decide fast on those; invest analysis in one-way doors.
Trade-offs
3 min| Structure | Fits when | You pay |
|---|---|---|
| Framework defaults | Prototypes, 1–3 people validating an idea | Rules spread; change cost grows |
| Typical business apps, one team | Logic depends on data-access details | |
| Rich rules, several integrations, long-lived | More interfaces and wiring |
- Karim · CTOLoyalty points ship in three weeks. Put the code wherever is fastest. We'll clean up later.
- Lina · Senior engineer, your mentor"Later" is how cancellation ended up in eleven places. One module with one interface first: two extra days.
- Karim · CTOTwo days is 10% of the deadline, for a feature that might flop.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Which team owns each business rule (free cancellation belongs to Booking) and which parts only display it. Claude will put the rule wherever the file it's editing happens to be.
- Whether a choice is a one-way door (the partner API, who writes to
bookings) or a two-way door (a date library). - Where boundaries are enforced, written in
CLAUDE.md: which folders may import the payment SDK, which module defines cancellation logic.
The free-cancellation rule (now 48 hours) is copied in about 11 places: booking API, admin panel, refund job, invoice PDF, 7 templates. Change nothing yet. List every place that computes the rule, with file and whether it computes or only displays. Then propose a plan: one pure function in core/booking owns it, every entry point calls it, templates receive the value as data, refunds go through a PaymentGateway interface. Same behaviour, no new service. Add tests for 47h, 48h, 49h.
- Compare Claude's list with your own search. A list of 6 when the incident said 11 means it stopped looking.
- After the change, exactly one file contains 48 for this rule; only the payments adapter imports the SDK.
- Tests cover 47, 48 and 49 hours, and you saw them run.
- It fixes the number in the file you pointed at and says "done", leaving the other copies.
- It proposes a new microservice or a rewrite for a one-function problem.
- The new core function imports the database client or the payment SDK.
Check yourself
2 minSelect all that apply.
Next episode: Karim reads a blog post over the weekend and wants to split StayHub into twelve microservices by summer.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
Two common shapes for the inside of an app:
- : controllers → services → repositories → database. Each layer calls the one below. Simple, and the default in most frameworks. Its weakness: business rules depend on data-access details.
- (ports and adapters): the core defines interfaces for what it needs; databases and providers plug in from outside. Every dependency points inward.
Lina gives the cancellation rule one home
The free-cancellation rule exists in 11 places and just drifted. Make the next change a one-file change, without a rewrite and without stopping feature work.
- 1Find every copy
Search the code for the rule's traces:
24,freeCancel,refundable. List each hit with its entry point: API, admin panel, refund job, invoice PDF, 7 templates.Why: You can't consolidate what you haven't found. The list also shows who must review the change.
- 2Name one owner
Decide which part owns the rule: the Booking area. Templates and the PDF only display the result; they never compute it.
Why: A rule with two owners drifts again. Ownership is the architecture decision; the code follows it.
- 3Give it one home
Write the rule once, as plain code with no HTTP or SDK inside.
ts// core/booking/cancellation-policy.ts export const FREE_CANCEL_HOURS = 48; export function canCancelFree(b: Booking, now: Date): boolean { return hoursBetween(now, b.checkIn) >= FREE_CANCEL_HOURS; }Why: Pure code is testable in milliseconds and can be called from anywhere: API, admin, jobs.
- 4Point every entry point at it
Replace the 11 copies with calls to
canCancelFree. Templates receivefreeCancelUntilas data instead of computing it.Why: This is the step that removes the drift. Until the last copy is gone, two answers are still possible.
- 5Put the provider behind a port
The core declares
interface PaymentGateway { refund(bookingId) }. The Stripe-style SDK lives in an adapter that implements it.Why: Refunds stop leaking provider details into the rule, and tests can use a fake gateway.
- 6Lock the boundary
Add a lint rule: only
adapters/paymentsmay import the payment SDK; nothing outsidecore/bookingmay define cancellation logic. Write a short decision record saying why.Why: Boundaries that aren't checked erode within months. The record tells the next engineer it was on purpose.
| Layered | Hexagonal (ports & adapters) | |
|---|---|---|
| Dependency direction | Top → bottom, logic depends on data access | Everything points inward to the core |
| Swap the database / provider | Touches service layer | Write a new adapter |
| Unit-test business rules | Often needs a DB or heavy mocks | Plain fakes, millisecond tests |
| Ceremony | Low | More interfaces and wiring |
| Good fit | CRUD-heavy apps, small teams | Rich business rules, several integrations, long-lived products |
The senior method, for any one-way door
- Problem
What are we actually solving? Here: a rule change costs six days and still drifts.
- Requirements
One answer for every screen; a policy change in under a day.
- Constraints
14 engineers, feature work can't stop, no rewrite.
- Options
Leave it and add tests; a shared helper file; a core module with ports; a separate service.
- Trade-offs
Tests catch drift but keep the copies. A service adds a network for no gain. A core module removes the copies at modest cost.
- Decision
Core module with ports, written down with a revisit trigger (e.g. if Booking needs its own release schedule).
Interactive simulation
Visual diagram
A booking core in hexagonal (ports & adapters) style. The core in the middle depends on nothing outside it.
Analogy
- In a hotel buildingWalls, plumbing, elevator shaftsIn softwareArchitecture decisions
- In a hotel buildingRepainting a roomIn softwareA two-way door
- In a hotel buildingOvens, fridges and sinks in one kitchenIn softwareHigh cohesion
- In a hotel buildingAll power running through room 101In softwareTight coupling
- In a hotel buildingStandard power socketsIn softwarePorts and adapters
- In a hotel buildingThe archive of building decisionsIn softwareDecision records (ADRs)
Where the analogy breaks: a building's walls really are fixed. In software, even "one-way doors" can be undone. It just costs a lot of time and money.
Check your understanding
Practice ladder
Trace a tourist-tax change
Scenario Warm-upLisbon raises its tourist tax from €2 to €4 per night. In StayHub's code today:
checkout.controller.tscomputes the tax to show the totalinvoice-pdf.tscomputes it again for the invoiceadmin/booking-edit.tscomputes it when staff change datesprice-summary.htmlshows the tax using a value passed in by the controller
Someone updates only checkout.controller.ts. A guest books 3 nights in Lisbon, then support moves her stay by one day.
Trace what the guest sees on the checkout page, the invoice, and after the admin edit. Then say which files would change if the rule had one home.
Finish Lina's plan for the late-checkout fee
Design PracticeLina's plan to give the late-checkout fee rule one home has two gaps. Today the rule ("€15 per hour after 11:00, max €60") is in the guest app API, the front-desk admin panel and the invoice job.
- Find every copy: search for
lateCheckout,11:00,60. - Name one owner: ___
- Give it one home: a pure
lateCheckoutFee(booking, checkoutTime)function in the core. - Point every entry point at it: ___
- Lock the boundary: a lint rule, plus a short decision record.
Fill in steps 2 and 4, and say why each matters.
Untangle the hotel commission
Design PracticeThe Hotel Partners team wants to change commission from a flat 15% to "15%, or 12% for hotels with more than 500 bookings a year". Today commission is calculated in:
- the monthly partner payout job
- the partner dashboard (shows "what you'll earn")
- a finance CSV export
- two SQL views used by reporting
The payout job also calls the bank-transfer provider's SDK directly. Propose a structure that makes this and future commission changes cheap. Say where the rule lives, how each caller uses it, how the bank provider fits in, and how you'd stop the copies from coming back.
One-way or two-way door?
Decision StretchThis week three decisions land on your desk. You have one afternoon for careful analysis.
- Which date library to use in the new loyalty screens.
- The JSON format of the new partner availability API that 1,800 hotels' channel managers will integrate with.
- Whether to move the email templates from one templating engine to another.
Pick the one that gets the afternoon, and write a short decision in this form: "We'll spend our analysis on X rather than Y because…, accepting the risk of…". Say how you'd handle the other two.
Explain it yourself
Explain to a junior developer what software architecture is, and why the team writes ADRs.