← Architecture
Organize an application Split by responsibility

Layered architecture

Each layer has one job, and calls only down.

You probably have routes/, services/, and db/ folders already, or a framework that suggests them. The folders are the easy part. Let’s build a clinic’s booking desk both ways, then add a second way in, and watch where the rules went.

TypeScriptGoOne booking desk, two shapes, two doors, four recorded builds.

01 / The prompt

“Build the booking API for our clinic.”

Dr. Osei has five slots on 2 October. Four rules decide a booking: the slot must exist, it must be free, a patient gets one appointment a day, and nobody books less than two hours ahead. Ask an agent for the API and you get a POST /appointments that follows all four. It works, and its tests pass.

A month later the clinic wants patients to book by text message too. The question the first prompt never answered is where the four rules live: in the handler that answers the web form, or somewhere a second door can reach. It does not matter until the second door exists, and then it decides whether “BOOK 1400” follows the same rules as the website.

Layering is the long-standing answer. Martin Fowler describes separating a program “into three broad layers: presentation (UI), domain logic (aka business logic), and data access,” where “presentation depends on the domain, which then depends on the data source” (Presentation Domain Data Layering). The web route and the text-message route are both presentation.

02 / Name the shape

Each layer has one job, and calls only down.

Layered architecture splits a server by responsibility: a layer that speaks the outside world’s language (HTTP, text messages), a layer that holds the rules, and a layer that stores data. Each calls only the layer below it.

Routes translate. Services decide. The db layer stores. A rule that lives in a route belongs to one door.

Who owns each part of a booking
WhatOwnerWhy
Reading JSON, or “BOOK 1400”A routeEach door has its own language. Nothing below should know it.
409, 422, or a sentence backA routeHow a refusal is said depends on the door.
The four booking rulesThe booking serviceThey are the clinic’s rules, whichever door the request came through.
The order the rules run inThe booking service“Taken” before “one per day” is a decision, made once.
Slots and appointmentsThe db layerThe only code that reads or writes rows, so storage can change in one place.
The current timePassed inA rule about “two hours ahead” needs a clock a test can set.

Words to put in a prompt or a review

Layer
A group of code with one responsibility, which calls only the layer below.
Route or controller
The layer that speaks HTTP, or text messages, and nothing else.
Service
The layer that holds the rules for one job, such as booking.
Data access layer
The only code that touches stored data.
Entry point
A door into the system: a web route, a text message, a job, a CLI.
Junk-drawer service
A service that collects everything nobody knew where to put.
Where MVC fitsA presentation pattern inside the top layer

Model–view–controller describes how the top layer is arranged: a controller takes input, a model holds state, a view renders it. In a web server the controller is the route. MVC says nothing about where the booking rules go; the “model” in many frameworks is the database row, and rules put there end up with the storage. Layering is the question underneath: which code may know about which.

03 / Follow one booking

Watch the same morning go through both builds.

Maya books 10:30 on the web, then tries 14:00 the same day, then texts BOOK 1400. Every step on screen is what the code recorded while handling it. Open Try it to send your own requests to both builds at once.

Layered architecture

Which code decides?

Layered · Web: POST /appointments p-17 10:30

Working…

01/ 05
A web booking, in layers

A request through the web form.

The request arrives.

Reduced motion: choose a scene to see its completed state.

Read this scene

The request arrives.

The request arrives.

Watch restarts the story when you come back. Step through keeps your step. Try it starts an empty diary each time you reset it.

04 / Read the shape

One service, two doors.

Basic form is the two lower layers: the store and the service that holds the rules. In the wild is the web door in both shapes. At the call site is the second door, where the shapes stop agreeing.

Notice what bookAppointment takes: a store, the time, a patient, and a slot. No request, no status code, no phone number. That signature is why a text message can use it unchanged.

The bottom two layers. The store is the only code that touches rows. The service holds the four booking rules, once, and knows nothing about HTTP or text messages.

TypeScriptReading
clinic.ts
// db/: the only code that touches stored data. It knows rows, not rules.
export class AppointmentStore {
	#rows: Booking[] = [];
	#next = 1;
	trace: Trace = [];

	slot(id: string): Slot | null {
		this.trace.push({ layer: 'db', did: `look up slot ${id}` });
		return slots.find((s) => s.id === id) ?? null;
	}
	bookingForSlot(slot: string): Booking | null {
		this.trace.push({ layer: 'db', did: `find a booking for ${slot}` });
		return this.#rows.find((b) => b.slot === slot) ?? null;
	}
	bookingsOnDay(patient: string, day: string): Booking[] {
		this.trace.push({ layer: 'db', did: `find ${patient}’s bookings on ${day}` });
		return this.#rows.filter((b) => b.patient === patient && b.slot.startsWith(day));
	}
	insert(patient: string, slot: string): Booking {
		const booking = { id: `a-${this.#next++}`, patient, slot };
		this.#rows.push(booking);
		this.trace.push({ layer: 'db', did: `insert ${booking.id}` });
		return { ...booking };
	}
	all(): Booking[] {
		return this.#rows.map((b) => ({ ...b }));
	}
}

// services/: the booking rules, once, for every door. No HTTP, no SMS, no SQL.
export type Refusal = 'no-such-slot' | 'taken' | 'one-per-day' | 'too-late';
export type Outcome = { ok: true; booking: Booking } | { ok: false; reason: Refusal };

export function bookAppointment(
	store: AppointmentStore,
	now: string,
	patient: string,
	slotId: string
): Outcome {
	const refuse = (reason: Refusal): Outcome => {
		store.trace.push({ layer: 'service', did: `refuse: ${reason}` });
		return { ok: false, reason };
	};
	const slot = store.slot(slotId);
	if (!slot) return refuse('no-such-slot');
	if (store.bookingForSlot(slot.id)) return refuse('taken');
	if (store.bookingsOnDay(patient, slot.id.slice(0, 10)).length) return refuse('one-per-day');
	if (minutes(slot.id) - minutes(now) < 120) return refuse('too-late');
	store.trace.push({ layer: 'service', did: 'all four rules pass' });
	return { ok: true, booking: store.insert(patient, slot.id) };
}
GoAlongside
main.go
// AppointmentStore is db/: the only code that touches stored data. It knows rows, not rules.
type AppointmentStore struct {
	rows  []Booking
	Trace []Step
}

func (s *AppointmentStore) Slot(id string) *Slot {
	s.Trace = append(s.Trace, Step{"db", "look up slot " + id})
	for _, slot := range Slots {
		if slot.ID == id {
			return &slot
		}
	}
	return nil
}

func (s *AppointmentStore) BookingForSlot(slot string) *Booking {
	s.Trace = append(s.Trace, Step{"db", "find a booking for " + slot})
	for _, b := range s.rows {
		if b.Slot == slot {
			return &b
		}
	}
	return nil
}

func (s *AppointmentStore) BookingsOnDay(patient, day string) []Booking {
	s.Trace = append(s.Trace, Step{"db", "find " + patient + "’s bookings on " + day})
	out := []Booking{}
	for _, b := range s.rows {
		if b.Patient == patient && strings.HasPrefix(b.Slot, day) {
			out = append(out, b)
		}
	}
	return out
}

func (s *AppointmentStore) Insert(patient, slot string) Booking {
	b := Booking{fmt.Sprintf("a-%d", len(s.rows)+1), patient, slot}
	s.rows = append(s.rows, b)
	s.Trace = append(s.Trace, Step{"db", "insert " + b.ID})
	return b
}

func (s *AppointmentStore) All() []Booking { return append([]Booking{}, s.rows...) }

// BookAppointment is services/: the booking rules, once, for every door. No HTTP, no SMS, no SQL.
// It returns the booking, or the reason it refused.
func BookAppointment(store *AppointmentStore, now, patient, slotID string) (*Booking, string) {
	refuse := func(reason string) (*Booking, string) {
		store.Trace = append(store.Trace, Step{"service", "refuse: " + reason})
		return nil, reason
	}
	slot := store.Slot(slotID)
	if slot == nil {
		return refuse("no-such-slot")
	}
	if store.BookingForSlot(slot.ID) != nil {
		return refuse("taken")
	}
	if len(store.BookingsOnDay(patient, slot.ID[:10])) > 0 {
		return refuse("one-per-day")
	}
	if minutes(slot.ID)-minutes(now) < 120 {
		return refuse("too-late")
	}
	store.Trace = append(store.Trace, Step{"service", "all four rules pass"})
	b := store.Insert(patient, slot.ID)
	return &b, ""
}
The behavior these examples promiseChecked by 12 shared scenarios
  • Rules in order: the slot exists, is free, the patient has no other appointment that day, and it is at least two hours away. Every case runs at 08:00 on 2 October.
  • The web door answers 201, 404, 409, 409, or 422, and 400 for a body it cannot read. The SMS door answers a sentence, and help text for anything that is not BOOK HHMM from a known phone.
  • The layered build sends both doors through one service. The route-first SMS handler has every rule except one per day.

Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.

Reading the TypeScriptA union for the service’s answer

The service returns Outcome, a union of a booking or a refusal with a reason. Each route maps the reason to its own reply: a status table for HTTP, a sentence table for SMS. The store keeps its rows in a private field, so no route can reach them.

Reading the GoTwo return values instead of a union

BookAppointment returns a booking pointer and a reason string, nil and empty meaning the other happened. The HTTP reply carries a typed body so the error JSON is {"error":"taken"} in both languages.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types clinic.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/layered-architecture

go 1.23
layered:
  web · 201 {"id":"a-1","patient":"p-17","slot":"2026-10-02T10:30"}
  web · 409 {"error":"one-per-day"}
  sms · “You already have an appointment that day.”
  sms · “That is less than 2 hours away. Please call us.”
  bookings: p-17 10:30
route-first:
  web · 201 {"id":"a-1","patient":"p-17","slot":"2026-10-02T10:30"}
  web · 409 {"error":"one-per-day"}
  sms · “Booked 14:00 with Dr. Osei.”
  sms · “That is less than 2 hours away. Please call us.”
  bookings: p-17 10:30, p-17 14:00

05 / Review the agent’s diff

“Patients could book on public holidays.”

The bug is real and the fix is two lines in the file where the request arrives. Before you merge, ask which doors that file is.

The agent’s pull request

“Patients could book on public holidays. Added a check to the appointments route; a new test posts a holiday slot and gets 422. All 17 tests pass.”

// routes/appointments.ts
			export function postAppointment(store, now, body) {
			  const request = parse(body);
			(added)  if (clinicClosed(request.slot)) // public holidays
			(added)    return { status: 422, body: { error: 'clinic-closed' } };
			  const outcome = bookAppointment(store, now, request.patientId, request.slot);
			
You are reviewing this change. What do you do?

06 / How it fails

A layer fails by knowing something it should not.

Rows backed by a shared case say so; the rest are marked as authored.

Failure modes of the booking desk
What goes wrongWhat a patient seesLayeredRoute-first
Wrong: a rule missing from one door (case)Two appointments on one day, booked by textRefused: both doors share the serviceBooked: the SMS handler lacks the rule
Conflicting: a taken slot from each door (case)“Sorry, that time is taken.”RefusedRefused: this rule was copied
Wrong input: an unreadable text (case)Help textRefused in the routeRefused in the handler
Slow: a query moves to a real database (authored)A slower replyOne change, in the db layerA change in every handler that queries
Leaking: the service imports HTTP types (authored)Nothing, yetThe next door has to fake a request to reach the rules
Junk drawer: the service collects email, PDFs, and reports (authored)Nothing, yetEvery change touches one file; split it by job, not by layer

The first row is the one that reaches a patient, and it is caught by giving every door the same service. Dependency direction covers why calls go only down, and Coupling and cohesion the junk drawer.

07 / Is it worth it?

Three folders cost indirection. Here is what they buy.

The route-first handler is shorter and easier to read top to bottom. Hold both builds up against the changes a clinic’s booking desk gets.

The same four changes, made to each build
ChangeRoute-firstLayered
A second door: booking by textA second handler with its own copy of the rulesA second route that calls the same service
Replace a dependency: a real databaseEvery handler that queries changesThe db layer changes
Change a rule: closed on public holidaysOne edit per handler, and one missedOne edit in the service
A second team takes the SMS integrationThey edit booking rules to change a replyThey own a route and call the service

Before restructuring, decide what you will measure and the result you would accept:

  • Rules checked per door. A test per rule per door; any rule that passes on one door and fails on another is the defect this shape prevents.
  • Files touched per rule change, from the last few changes’ diffs, before and after.
  • Rule tests that need a server running. Run them with networking blocked, as section 08 does; the layered build’s should all pass.

This page did not run a real clinic, so it gives no production numbers.

08 / Ask for it

Two prompts, one ticket, four builds.

We sent two agents the same request at the same time, both running Claude Sonnet. One prompt described the API. The other added an Architecture block: routes/, services/, and db/, calls only downward, every rule in the service, and rule tests with no server running. Then each finished build went to a fresh agent with the same ticket: patients can now book by text, and the same rules apply. A script sent every rule through both doors of every build.

What the checker found, run 2026-09-23, at 08:00 on 2 October
QuestionPlain promptArchitecture prompt
Imports between foldersNone: one fileroutes → services; services → db
Rule tests with networking blocked, before the ticket0 of 8 pass with networking blocked8 of 8 pass with networking blocked
After the ticket: web and SMS agree on the rule cases5 of 55 of 5
SMS: a free slotBookedBooked
SMS: a slot that does not existRefusedRefused
SMS: a taken slotRefusedRefused
SMS: a second appointment the same dayRefusedRefused
SMS: less than 2 hours aheadRefusedRefused
The web’s one-per-day error is reworded; Maya texts for a second slot“Sorry, we couldn't book that appointment.”“That time has just been taken. Please try another time.”
Tests with networking blocked, after the ticket0 of 18 pass with networking blocked18 of 21 pass with networking blocked

The failure this lesson is built around did not happen. Both text-message doors enforced all four rules, because both builds already had one booking function and the ticket said the same rules apply. The plain build had no layers, but it had not put its rules in the route either.

What the checker found instead is how the refusal traveled. In the plain build, the SMS reply is looked up by the web’s error string. In the layered build, the service throws an error carrying an HTTP status code, and the SMS code picks its sentence from the status and, for the two 409s, from the error’s wording.

SMS ticket · plain build
+const SMS_BOOKING_ERROR_MESSAGES: Record<string, string> = {
+  "slot not found":
+    "Sorry, that time isn't available on 2 Oct 2026. Text BOOK and one of: 0900, 0930, 1000, 1030, 1400.",
+  "slot already booked": "Sorry, that time is already booked. Please try a different time.",
+  "patient already has an appointment that day":
+    "You already have an appointment booked for 2 Oct 2026.",
+  "booking must be at least 2 hours before the slot":
+    "Sorry, bookings must be made at least 2 hours before the appointment time.",
+};
SMS ticket · layered build
+function describeBookingFailure(err: BookingError): string {
+  switch (err.status) {
+    case 404:
+      return "That's not a clinic time on 2 Oct. Please text a valid time, e.g. BOOK 0930.";
+    case 409:
+      return err.message.includes("already has an appointment")
+        ? "You already have an appointment booked that day."
+        : "That time has just been taken. Please try another time.";
+    case 422:
+      return "Bookings need at least 2 hours notice. Please choose a later time.";
+    default:
+      return "Sorry, we couldn't book that appointment.";
+  }
+}

So the checker reworded the web’s one-per-day message, the kind of edit a copy review makes, and asked again. The plain build’s text message fell back to a vague apology. The layered build’s told Maya the time had just been taken, which is false. Its service, the layer that was supposed to know nothing about HTTP, was speaking in status codes.

The line both prompts lacked: the service returns a named reason for every refusal, and each door turns that reason into its own words. That is what bookAppointment does in section 04. The layered prompt’s one clear win was testing: all of its rule tests ran without a server, and none of the plain build’s did.

How the runs were made and checkedTwo rounds, recorded as written
  • Both round-one agents received the prompts word for word, in fresh contexts, in the same message. Each finished build was stored, copied, and given to a fresh agent with the same ticket; the two ticket agents also started together.
  • Every file is kept byte for byte, with checksums, and each ticket is kept as a diff. The checker restores a build, starts it at a fixed time, and decides whether a request booked by reading the appointment list, not the reply’s wording.
  • The reworded-error question was added after reading both ticket diffs, and changes one line of each agent’s code in a temporary copy. The checker’s second run, before that question, is kept as text; its JSON was deleted by mistake.
  • Every agent stopped its test server by process id, as the prompts asked. Three wrote a log to the system’s temporary folder, against the prompt. None read another run’s folder.
  • This is one sample of each prompt and ticket, not a measurement of a model.

09 / Hold it there

Make the direction a rule, and test the rules at the service.

Layers erode one shortcut at a time: a route that queries the store because the service lacked a method, a service that takes a request object because it was handy. Three checks keep them.

  1. The language’s own door

    Go’s internal/ directories make a package importable only from inside the tree that holds it, so db/ can live under internal/ beside services/ and a route package outside cannot import it. In SvelteKit, $lib/server keeps the service and the store out of anything that reaches the browser (server-only modules). Neither stops a route on the server from reaching the store; that takes the next check.

  2. An import rule an agent cannot argue with

    Write the direction down as a rule: routes do not import db, services do not import routes or HTTP, db imports neither. Enforcement layer runs rules like this against real code, and Architecture as rules writes them from one declaration. The checker in section 08 scanned the recorded builds’ imports the same way; dependency-cruiser itself was not run here.

    .dependency-cruiser.cjs
    // .dependency-cruiser.cjs
    module.exports = {
    	forbidden: [
    		{ name: 'routes-skip-no-layer', severity: 'error', from: { path: '^routes/' }, to: { path: '^db/' } },
    		{ name: 'services-never-call-up', severity: 'error', from: { path: '^services/' }, to: { path: '^routes/' } },
    		{ name: 'db-knows-no-rules', severity: 'error', from: { path: '^db/' }, to: { path: '^(services|routes)/' } },
    		{
    			name: 'services-speak-no-http',
    			severity: 'error',
    			from: { path: '^services/' },
    			to: { path: '^node:http$|^express$' }
    		}
    	]
    };
  3. A check on what actually happens

    An import rule cannot see a rule copied into two routes. So send the same request through every door and compare, which is what the checker does, and run the rule tests with networking blocked: tests that pass that way are testing the service, not a server.

    check-runs.mjs
    const ruleCases = {
    	'a free slot': { web: [web('p-17', '10:30')], sms: [sms(MAYA, 'BOOK 1030')] },
    	'a slot that does not exist': { web: [web('p-17', '11:00')], sms: [sms(MAYA, 'BOOK 1100')] },
    	'a taken slot': { web: [web('p-22', '10:30'), web('p-17', '10:30')], sms: [web('p-22', '10:30'), sms(MAYA, 'BOOK 1030')] },
    	'a second appointment the same day': { web: [web('p-17', '10:30'), web('p-17', '14:00')], sms: [web('p-17', '10:30'), sms(MAYA, 'BOOK 1400')] },
    	'less than 2 hours ahead': { web: [web('p-17', '09:30')], sms: [sms(MAYA, 'BOOK 0930')] }
    };
    
    // A copy editor rewords the web's one-per-day error. Where each build's booking
    // rule produces that text, from the agents' own code:
    const rewordAt = {
Your components already have layersA component that never calls fetch is the top layer. A rule two screens share is the service.

Where it already is in your components

A component that calls bookAppointment() from an api/ module instead of fetch is layered: the component renders, the api module owns URLs, JSON, and status codes. A SvelteKit page whose +page.server.ts calls $lib/server services has the same three layers the clinic has.

When you have to own it

The day two screens need the same rule, such as the booking page and a reschedule dialog both greying out slots a patient cannot take. Put the rule in one module both import, not in each component’s click handler, or the dialog will drift the way the SMS handler did. The server still enforces the rule; the shared module only lets the screens agree with it.

A booking button that asks the api layer, which owns URLs and status codes, and turns the answer into a result the component can show.

ReactAlready in your code
BookButton.tsx
// BookButton.tsx. The component never calls fetch. It asks the api layer,
// which owns URLs, JSON, and status codes, and turns them into a result.
import { useState } from 'react';
import { bookAppointment, type BookingResult } from './api/appointments';

export function BookButton({ patientId, slot }: { patientId: string; slot: string }) {
	const [result, setResult] = useState<BookingResult | null>(null);
	return (
		<div>
			<button onClick={async () => setResult(await bookAppointment(patientId, slot))}>
				Book {slot.slice(11)}
			</button>
			{result && <p role="status">{result.ok ? 'Booked.' : result.message}</p>}
		</div>
	);
}

10 / Make the call

Layer when a second door is coming. Slice when the service gets big.

A single handler with its rules inside is fine while there is one door and the rules fit on a screen: a webhook receiver, an internal admin endpoint, a prototype. Reopen it the day a second door appears, a second team edits it, or a rule has to be tested without starting a server.

Layer the server when rules are shared across doors or change often. Watch the service layer: when services/ becomes one folder with booking, billing, reminders, and reports in it, split the top level by feature and layer inside each, as Fowler puts it, “domain oriented modules which are internally layered.” That is the next lesson.

Take it with you

Explain it without saying “layered”: “The code that reads a web form or a text message only translates. The clinic’s rules sit in one place that every door calls, and only one part of the code touches the stored appointments.” Then find a rule in your own code that lives in a route handler, and count the doors that should obey it.

Paste into your next prompt, and fill in the blanks

Organize the server in layers: <routes/> (HTTP only: parse, call a service,
choose the status code), <services/> (the <job> rules, with no HTTP types),
and <db/> (the only code that reads or writes stored data).
Routes call services, services call db, and nothing calls upward.
Every rule lives in <services/>, once, and every door calls it.
Test the rules by calling the service directly, with no server running.
Connections to follow nextRelated lessons

Take the clinic into your editor. Add a third door, a front-desk screen that books for a patient by name, and write the test that proves it follows all four rules.

Back to architecture →