← Concepts & practices
Choice Boundaries and contracts

Module boundaries

Draw the line between features.

A workshop enrollment page needs a session title and a member name. It can ask the owning modules for those projections, or it can reach through folders, read their tables, and quietly become another owner. Follow the arrows from page to Enrollment to Catalog, then see what a cycle and an unenforced convention cost.

The judgment to keep

A module boundary is an ownership rule: callers cross a named public surface, dependencies point in an intentional direction, and an automated check makes the shortcut fail before it becomes architecture.

TypeScriptGo One workshop enrollment flow · two boundary implementations.
Start with the line

A folder can organize code without owning anything.

Imagine three feature folders: catalog, members, and enrollment. Their names suggest separation, but a relative import can still open any file inside them. If the page imports sessionRows, it knows storage, private notes, and seat mutation. If Catalog imports Enrollment to count registrations, the features form a cycle and neither can change alone.

The useful boundary is not “this code lives in another directory.” It is “this code may be asked for these named things, and it may depend on those named owners.” A boundary has both a public surface and a dependency policy.

Keep data and rules with the feature that owns them. Export a projection or operation for a caller’s need, then make the allowed arrow visible and enforceable.

Read the folder-shaped startTypeScript · organization is not encapsulation
boundary.ts · folders only
// The folder names suggest boundaries, but these exports make every detail public.
export const sessionRows = rawSessions;
export const memberRows = rawMembers;

export function enrollDirect(sessionId: string, memberId: string): string | null {
	const session = sessionRows.find((item) => item.id === sessionId);
	const member = memberRows.find((item) => item.id === memberId);
	if (!session || !member || session.seatsLeft < 1) return null;
	session.seatsLeft -= 1;
	return `${member.name} · ${session.title}`;
}

The first version is convenient: the page can find a session and member in one expression. It also reaches private notes, changes Catalog’s seat count from outside Catalog, and duplicates the feature’s missing-member policy. The code has folders, but no dependable line.

Draw the arrows

Good boundaries make ownership legible.

Start from the use case, not from the tables. The enrollment application owns the workflow. Catalog owns session meaning. Members owns profile meaning. The page owns presentation, not seat counts or database rows.

01Page → Application facade

Commands and views written for the screen.

02Enrollment → Catalog

A session projection, never Catalog’s storage record.

03Enrollment → Members

A member projection, never a database client.

04Coordinator → Features

Reverse reads belong in a report, query, event, or coordinator.

Compare a direct import with an owned crossing
NeedShortcutBoundary-shaped choice
Page needs a receiptImport Enrollment’s storeCall the application facade
Enrollment needs a titleImport Catalog’s rowCall Catalog’s projection
Catalog needs countsImport EnrollmentMove the read to a coordinator or event
Analytics needs historyRead another feature’s tableExpose a report-shaped query
Read the Go version of visibilityGo · package scope and exported types
boundary.go · package visibility
type catalogModule struct{}

func NewCatalog() catalogModule { return catalogModule{} }

func (catalogModule) GetSession(id string) *SessionCard {
	for _, session := range rawSessions {
		if session.ID == id {
			card := session.SessionCard
			return &card
		}
	}
	return nil
}

// ReserveSeat is the only code that changes a seat count, because Catalog owns it.
func (catalogModule) ReserveSeat(id string) bool {
	for index := range rawSessions {
		if rawSessions[index].ID == id {
			if rawSessions[index].SeatsLeft < 1 {
				return false
			}
			rawSessions[index].SeatsLeft--
			return true
		}
	}
	return false
}

type membersModule struct{}

func NewMembers() membersModule { return membersModule{} }

func (membersModule) GetMember(id string) *MemberProfile {
	for _, member := range rawMembers {
		if member.ID == id {
			profile := member.MemberProfile
			return &profile
		}
	}
	return nil
}

type enrollmentModule struct {
	readSession func(string) *SessionCard
	readMember  func(string) *MemberProfile
	reserveSeat func(string) bool
	enrolled    map[string]enrollmentView
}

func NewEnrollment(readSession func(string) *SessionCard, readMember func(string) *MemberProfile, reserveSeat func(string) bool) enrollmentModule {
	return enrollmentModule{readSession: readSession, readMember: readMember, reserveSeat: reserveSeat, enrolled: map[string]enrollmentView{}}
}

func (module enrollmentModule) Enroll(sessionID, memberID string) enrollmentResult {
	key := sessionID + ":" + memberID
	if _, exists := module.enrolled[key]; exists {
		return enrollmentResult{Kind: "rejected", Reason: "already-enrolled"}
	}
	session := module.readSession(sessionID)
	if session == nil {
		return enrollmentResult{Kind: "rejected", Reason: "unknown-session"}
	}
	if session.SeatsLeft < 1 {
		return enrollmentResult{Kind: "rejected", Reason: "session-full"}
	}
	member := module.readMember(memberID)
	if member == nil {
		return enrollmentResult{Kind: "rejected", Reason: "unknown-member"}
	}
	if !module.reserveSeat(sessionID) {
		return enrollmentResult{Kind: "rejected", Reason: "session-full"}
	}
	receipt := enrollmentView{ConfirmationID: fmt.Sprintf("confirmation-%d", len(module.enrolled)+1), SessionTitle: session.Title, MemberName: member.Name}
	module.enrolled[key] = receipt
	return enrollmentResult{Kind: "enrolled", Receipt: &receipt}
}

func (module enrollmentModule) GetReceipt(sessionID, memberID string) *enrollmentView {
	receipt, exists := module.enrolled[sessionID+":"+memberID]
	if !exists {
		return nil
	}
	return &receipt
}

Go keeps the storage records package-private and exposes projections through methods whose names begin with capitals. The language can hide a name inside a package; the application still needs a rule about which package may import which other package. The example keeps everything in one package main so a single file runs; in an application Catalog, Members, and Enrollment would be separate packages, and only then do the lowercase names stay hidden.

Test a crossing

A convention helps; an enforced graph holds.

Choose how much structure the code has and send one dependency across it. “Folders only” leaves every shortcut open. “Public entrypoints” gives reviewers a vocabulary, but a deep relative import can still bypass it. An entrypoint plus an import rule makes the forbidden arrow fail in CI.

Workshop enrollment

Choose the boundary, then send an import across it.

Runs a local dependency model
Web page→Enrollment public API
01Web page needs something from Enrollment public API
02The request crosses folders only
03The page imports the feature entrypoint, not its store.
04Add an import rule and an architecture test before the code grows.
Crossing

A page calls `enroll()` and receives a receipt view.

Check

No automated check: a relative import can reach any file.

The design has a convention, not yet a dependable boundary.

Watch for A public entrypoint is not permission to expose every type behind it.

The controls model dependency policy; they do not inspect your repository or run an import.
Practice the boundary

Review the arrow before reviewing the syntax.

Choose the move that keeps ownership and dependency direction clear.

What turns a folder into a dependable module boundary?
Enrollment needs a session title. Which dependency is healthiest?
Catalog needs registration counts and importing Enrollment would create a cycle. What next?
What belongs in a shared module?
Feedback stays on this page; it is not saved.
Give ownership a shape

Expose a projection, not the object that happened to produce it.

The example’s Catalog returns SessionCard, not SessionRecord. Members returns a name, not an email-bearing row. Enrollment turns those inputs into an EnrollmentView and keeps its receipt store private. Each shape answers a caller’s question without making the caller responsible for another feature’s invariants.

That design also leaves room for a change. Catalog can replace its array with a database or remote client. Enrollment can change its receipt id or duplicate policy. The application edge is the place where those modules meet; the page does not need to follow their internals.

Catalog and Members return projections; Enrollment receives readers and asks Catalog to reserve the seat.

TypeScriptReading
boundary.ts
type SessionReader = (id: string) => SessionCard | null;
type MemberReader = (id: string) => MemberProfile | null;
type SeatReserver = (id: string) => boolean;

export function createCatalogModule() {
	return {
		getSession(id: string): SessionCard | null {
			const session = rawSessions.find((item) => item.id === id);
			return session
				? { id: session.id, title: session.title, seatsLeft: session.seatsLeft }
				: null;
		},
		// Catalog owns the seat count, so only Catalog changes it.
		reserveSeat(id: string): boolean {
			const session = rawSessions.find((item) => item.id === id);
			if (!session || session.seatsLeft < 1) return false;
			session.seatsLeft -= 1;
			return true;
		}
	};
}

export function createMembersModule() {
	return {
		getMember(id: string): MemberProfile | null {
			const member = rawMembers.find((item) => item.id === id);
			return member ? { id: member.id, name: member.name } : null;
		}
	};
}

export function createEnrollmentModule(dependencies: {
	readSession: SessionReader;
	readMember: MemberReader;
	reserveSeat: SeatReserver;
}) {
	const enrolled = new Map<string, EnrollmentView>();

	function enroll(sessionId: string, memberId: string): EnrollmentResult {
		const key = `${sessionId}:${memberId}`;
		if (enrolled.has(key)) return { kind: 'rejected', reason: 'already-enrolled' };
		const session = dependencies.readSession(sessionId);
		if (!session) return { kind: 'rejected', reason: 'unknown-session' };
		if (session.seatsLeft < 1) return { kind: 'rejected', reason: 'session-full' };
		const member = dependencies.readMember(memberId);
		if (!member) return { kind: 'rejected', reason: 'unknown-member' };
		if (!dependencies.reserveSeat(sessionId)) return { kind: 'rejected', reason: 'session-full' };
		const receipt = {
			confirmationId: `confirmation-${enrolled.size + 1}`,
			sessionTitle: session.title,
			memberName: member.name
		};
		enrolled.set(key, receipt);
		return { kind: 'enrolled', receipt };
	}

	function getReceipt(sessionId: string, memberId: string): EnrollmentView | null {
		return enrolled.get(`${sessionId}:${memberId}`) ?? null;
	}

	return { enroll, getReceipt };
}
GoAlongside
boundary.go
type catalogModule struct{}

func NewCatalog() catalogModule { return catalogModule{} }

func (catalogModule) GetSession(id string) *SessionCard {
	for _, session := range rawSessions {
		if session.ID == id {
			card := session.SessionCard
			return &card
		}
	}
	return nil
}

// ReserveSeat is the only code that changes a seat count, because Catalog owns it.
func (catalogModule) ReserveSeat(id string) bool {
	for index := range rawSessions {
		if rawSessions[index].ID == id {
			if rawSessions[index].SeatsLeft < 1 {
				return false
			}
			rawSessions[index].SeatsLeft--
			return true
		}
	}
	return false
}

type membersModule struct{}

func NewMembers() membersModule { return membersModule{} }

func (membersModule) GetMember(id string) *MemberProfile {
	for _, member := range rawMembers {
		if member.ID == id {
			profile := member.MemberProfile
			return &profile
		}
	}
	return nil
}

type enrollmentModule struct {
	readSession func(string) *SessionCard
	readMember  func(string) *MemberProfile
	reserveSeat func(string) bool
	enrolled    map[string]enrollmentView
}

func NewEnrollment(readSession func(string) *SessionCard, readMember func(string) *MemberProfile, reserveSeat func(string) bool) enrollmentModule {
	return enrollmentModule{readSession: readSession, readMember: readMember, reserveSeat: reserveSeat, enrolled: map[string]enrollmentView{}}
}

func (module enrollmentModule) Enroll(sessionID, memberID string) enrollmentResult {
	key := sessionID + ":" + memberID
	if _, exists := module.enrolled[key]; exists {
		return enrollmentResult{Kind: "rejected", Reason: "already-enrolled"}
	}
	session := module.readSession(sessionID)
	if session == nil {
		return enrollmentResult{Kind: "rejected", Reason: "unknown-session"}
	}
	if session.SeatsLeft < 1 {
		return enrollmentResult{Kind: "rejected", Reason: "session-full"}
	}
	member := module.readMember(memberID)
	if member == nil {
		return enrollmentResult{Kind: "rejected", Reason: "unknown-member"}
	}
	if !module.reserveSeat(sessionID) {
		return enrollmentResult{Kind: "rejected", Reason: "session-full"}
	}
	receipt := enrollmentView{ConfirmationID: fmt.Sprintf("confirmation-%d", len(module.enrolled)+1), SessionTitle: session.Title, MemberName: member.Name}
	module.enrolled[key] = receipt
	return enrollmentResult{Kind: "enrolled", Receipt: &receipt}
}

func (module enrollmentModule) GetReceipt(sessionID, memberID string) *enrollmentView {
	receipt, exists := module.enrolled[sessionID+":"+memberID]
	if !exists {
		return nil
	}
	return &receipt
}
Public edge

Named operations

Export the question a caller needs: read a projection, submit a command, or ask for a report.

Private core

Owned invariants

Keep tables, private fields, and rule-changing helpers with the module that owns them.

Architecture check

Allowed arrows

Test import direction in CI so a convenient shortcut cannot silently become a dependency.

Recognize it in UI code

A component should render a feature’s result, not operate its storage.

Build frontends?The page is a consumer of a feature, not its storage owner.

Where it already is in your components

Every import at the top of a component file is an arrow in a module graph. A page that calls the feature’s public enroll depends on that feature’s promise; a page that imports its rows depends on its storage.

When you have to own it

When a page needs data from two features, own which surface it crosses. The textbook components receive an application facade. Their handler submits an enrollment and renders its discriminated result. The wild components import rows, decrement seats, and log an email and private note. That code may work today, but the page has become part of Catalog and Members’ implementation.

The page receives the application facade and renders an owned receipt-shaped result.

ReactAlready in your code
textbook.tsx · facade import
import { useState } from 'react';

type EnrollmentResult =
	| {
			kind: 'enrolled';
			receipt: { confirmationId: string; sessionTitle: string; memberName: string };
	  }
	| { kind: 'rejected'; reason: string };

type WorkshopApp = {
	enroll(sessionId: string, memberId: string): EnrollmentResult;
};

export function EnrollmentPanel({
	app,
	sessionId,
	memberId
}: {
	app: WorkshopApp;
	sessionId: string;
	memberId: string;
}) {
	const [result, setResult] = useState<EnrollmentResult | null>(null);

	function submit() {
		setResult(app.enroll(sessionId, memberId));
	}

	return (
		<section>
			<button type="button" onClick={submit}>
				Enroll
			</button>
			{result?.kind === 'enrolled' ? <p>Confirmed: {result.receipt.sessionTitle}</p> : null}
			{result?.kind === 'rejected' ? <p>Could not enroll: {result.reason}</p> : null}
		</section>
	);
}
Keep the line honest

Most boundary failures begin as reasonable shortcuts.

01

The shared folder

“Shared” often becomes a second domain owner. Put only stable, feature-neutral primitives there.

02

The barrel export

A convenient index can accidentally re-export internals. Export the intended surface deliberately.

03

The reverse query

A feature asking its consumer for data usually signals a missing coordinator, report, or event.

04

The false abstraction

Do not invent a generic repository or service just to avoid one import. Name the ownership problem first.

What an architecture test should sayA rule is part of the boundary, not a one-time diagram

Pin the graph in a test or linter rule: the web layer may import feature entrypoints; a feature may import another feature’s public API when the direction is approved; internals and storage are never cross-feature imports; and cycles fail. Keep the rule close to the module map so a new feature has to declare its owner and allowed neighbors.

boundary.ts · import rule
// The import rule an architecture test would enforce: each module lists what it may import.
export type ModuleName = 'web' | 'app' | 'enrollment' | 'catalog' | 'members';
export type ImportGraph = Readonly<Record<ModuleName, readonly ModuleName[]>>;

export const allowedImports: ImportGraph = {
	web: ['app'],
	app: ['enrollment', 'catalog', 'members'],
	enrollment: ['catalog', 'members'],
	catalog: [],
	members: []
};

export function checkImport(graph: ImportGraph, from: ModuleName, to: ModuleName) {
	return graph[from].includes(to) ? 'allowed' : 'blocked';
}

export function findCycle(graph: ImportGraph): ModuleName[] | null {
	const done = new Set<ModuleName>();
	const visit = (name: ModuleName, path: ModuleName[]): ModuleName[] | null => {
		if (path.includes(name)) return [...path.slice(path.indexOf(name)), name];
		if (done.has(name)) return null;
		for (const next of graph[name]) {
			const cycle = visit(next, [...path, name]);
			if (cycle) return cycle;
		}
		done.add(name);
		return null;
	};
	for (const name of Object.keys(graph) as ModuleName[]) {
		const cycle = visit(name, []);
		if (cycle) return cycle;
	}
	return null;
}
boundary.go · import rule
// The import rule an architecture test would enforce: each module lists what it may import.
type importGraph map[string][]string

var moduleOrder = []string{"web", "app", "enrollment", "catalog", "members"}

var allowedImports = importGraph{
	"web":        {"app"},
	"app":        {"enrollment", "catalog", "members"},
	"enrollment": {"catalog", "members"},
	"catalog":    {},
	"members":    {},
}

func checkImport(graph importGraph, from, to string) string {
	if slices.Contains(graph[from], to) {
		return "allowed"
	}
	return "blocked"
}

func findCycle(graph importGraph) []string {
	done := map[string]bool{}
	var visit func(name string, path []string) []string
	visit = func(name string, path []string) []string {
		if at := slices.Index(path, name); at >= 0 {
			return append(slices.Clone(path[at:]), name)
		}
		if done[name] {
			return nil
		}
		for _, next := range graph[name] {
			if cycle := visit(next, append(slices.Clone(path), name)); cycle != nil {
				return cycle
			}
		}
		done[name] = true
		return nil
	}
	for _, name := range moduleOrder {
		if cycle := visit(name, nil); cycle != nil {
			return cycle
		}
	}
	return nil
}

func withImport(graph importGraph, from, to string) importGraph {
	next := importGraph{}
	for name, imports := range graph {
		next[name] = slices.Clone(imports)
	}
	next[from] = append(next[from], to)
	return next
}

The example writes the rule as data so the run can check it: Enrollment may import Catalog, Catalog may not import Enrollment, and adding that reverse import produces the cycle enrollment → catalog → enrollment. In a real repository the same table lives in a lint rule or architecture test that reads the actual imports.

Make the call

Choose a boundary when ownership deserves a name.

Keep code together when one owner changes it, the state is local, and no caller needs a stable surface. Draw a boundary when a feature has its own rules, several callers need it, a team owns it, or a future process/service split would benefit from a narrow seam.

Then write three things down: the public operations and projections, the direction of allowed dependencies, and the check that rejects everything else. A boundary that exists only in a diagram will lose to the next convenient import.

Keep this questionUse it in a design review.

Who owns this rule, and what is the smallest named surface another module needs?

Take the idea with you

Boundaries are the local version of a contract.

When a dependency crosses a line, make the owner and the promise visible.

Connections to follow nextRelated lessons
  • Module explains how a file or package hides its implementation behind exports.
  • Coupling and cohesion asks whether code changes together for a good reason.
  • Module contracts asks what remains true when a module crosses a network.
  • Ports and adapters moves the same ownership question around an application core.
Why
Enrollment, Catalog, and Members change for different reasons and have different owners.
What
Callers use each module’s public entrypoint; Enrollment may import Catalog, never the reverse.
Constraint
A convention alone loses to the next convenient import, so a check must enforce the arrows.
Fallback
A read that would create a cycle moves to a coordinator or an event.
Reconsider when
One owner changes both sides together and no caller needs a stable surface.