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
// 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.
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.
Commands and views written for the screen.
A session projection, never Catalog’s storage record.
A member projection, never a database client.
Reverse reads belong in a report, query, event, or coordinator.
| Need | Shortcut | Boundary-shaped choice |
|---|---|---|
| Page needs a receipt | Import Enrollment’s store | Call the application facade |
| Enrollment needs a title | Import Catalog’s row | Call Catalog’s projection |
| Catalog needs counts | Import Enrollment | Move the read to a coordinator or event |
| Analytics needs history | Read another feature’s table | Expose a report-shaped query |
Read the Go version of visibilityGo · package scope and exported types
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.
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.
Choose the boundary, then send an import across it.
A page calls `enroll()` and receives a receipt view.
No automated check: a relative import can reach any file.
Watch for A public entrypoint is not permission to expose every type behind it.
Review the arrow before reviewing the syntax.
Choose the move that keeps ownership and dependency direction clear.
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.
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 };
} 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
} Named operations
Export the question a caller needs: read a projection, submit a command, or ask for a report.
Owned invariants
Keep tables, private fields, and rule-changing helpers with the module that owns them.
Allowed arrows
Test import direction in CI so a convenient shortcut cannot silently become a dependency.
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.
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>
);
}
Most boundary failures begin as reasonable shortcuts.
The shared folder
“Shared” often becomes a second domain owner. Put only stable, feature-neutral primitives there.
The barrel export
A convenient index can accidentally re-export internals. Export the intended surface deliberately.
The reverse query
A feature asking its consumer for data usually signals a missing coordinator, report, or event.
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.
// 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;
} // 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.
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?
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.