Locality of Behavior
The prime directive of code organization: the behavior of a unit should be obvious by looking only at that unit.
Not by looking at that unit plus three other files. Not by grepping the codebase. Not by asking the person who wrote it. By looking at the unit itself.
Every time you must open another file to understand the current file, you pay a cognitive tax. Every hop is a context switch. Every context switch is a chance to lose your mental model. Minimize hops.
Coupling: The Invisible Enemy
Coupling is the degree to which changing one module requires changing another. High coupling means changes ripple. Low coupling means changes are local.
// High coupling: changing any part requires understanding all parts
// File 1: userValidator.ts
export function validateUser(input: unknown): ValidationResult { /* ... */ }
// File 2: userMapper.ts
export function mapToUser(validated: ValidatedInput): User { /* ... */ }
// File 3: userRepository.ts
export function saveUser(user: User): Promise<void> { /* ... */ }
// File 4: userEventEmitter.ts
export function emitUserCreated(user: User): void { /* ... */ }
// File 5: userLogger.ts
export function logUserCreation(user: User): void { /* ... */ }
// File 6: userService.ts
export function createUser(input: unknown): Promise<User> {
const validated = validateUser(input);
if (!validated.ok) throw validated.error;
const user = mapToUser(validated.data);
await saveUser(user);
emitUserCreated(user);
logUserCreation(user);
return user;
}
// File 7: userController.ts
export function handleCreateUser(req: Request): Response {
const user = await createUser(req.body);
return { status: 201, body: user };
}Seven files to understand user creation. Change the validation rules? Modify userValidator.ts, hope the mapper still works. Change the data shape? Update mapper, repository, event emitter, logger. Every change requires reading multiple files to understand the impact.
This is "separation of concerns" taken to its logical extreme. Each "concern" is separated into its own file. But the concerns are not independent. They are tightly coupled by the data flow. Separating them by file does not reduce coupling; it hides it.
Levels of Indirection
Each function call to another module is a level of indirection. Each level is a hop you must make to understand the behavior.
handleCreateUser → createUser → validateUser → validation rules
→ mapToUser → mapping logic
→ saveUser → database query
→ emitUserCreated → event handlers
→ logUserCreation → log formatFive levels of indirection for one operation. To understand what happens when a user is created, you must read seven files.
Each hop is a tax on understanding. One or two hops are fine. Five or more means your architecture is working against you.
Co-locating Related Code
The fix is not "put everything in one file." The fix is "put related code together."
// signup.ts: everything about the signup flow, in one place
interface SignupInput {
readonly name: string;
readonly email: string;
readonly password: string;
}
interface SignupResult {
readonly user: User;
readonly welcomeEmailSent: boolean;
}
export async function signup(input: SignupInput, deps: SignupDeps): Promise<Result<SignupResult, SignupError>> {
// Validate
if (!input.name.trim()) return err("name_required");
if (!input.email.includes("@")) return err("invalid_email");
if (input.password.length < 8) return err("password_too_short");
// Check uniqueness
const existing = await deps.db.findByEmail(input.email);
if (existing) return err("email_taken");
// Create user
const user: User = {
id: crypto.randomUUID(),
name: input.name.trim(),
email: input.email.toLowerCase(),
passwordHash: await hashPassword(input.password),
createdAt: new Date(),
};
await deps.db.insert(user);
// Send welcome email (non-critical, do not fail signup)
let welcomeEmailSent = false;
try {
await deps.mailer.send(user.email, "Welcome!", `Hello ${user.name}`);
welcomeEmailSent = true;
} catch {
deps.logger.warn("Failed to send welcome email", { userId: user.id });
}
return ok({ user, welcomeEmailSent });
}One file. One function. You read it top to bottom and you understand the entire signup flow: validation, uniqueness check, user creation, welcome email. No hopping between files. No hunting for where validation happens.
The dependencies are injected (deps), so the function is testable. The types are co-located, so you do not need to import from three separate type files.
When Separation of Concerns Goes Too Far
"Separation of concerns" is often used to justify scattering related code across many files by category: all validators in one folder, all mappers in another, all repositories in a third.
src/
validators/
userValidator.ts
orderValidator.ts
productValidator.ts
mappers/
userMapper.ts
orderMapper.ts
productMapper.ts
repositories/
userRepository.ts
orderRepository.ts
productRepository.ts
services/
userService.ts
orderService.ts
productService.tsThis structure groups by technical layer. But when you work on a feature, you work across layers. Adding a field to the user requires touching four files across four directories. The code that changes together is scattered.
Organize by feature, not by layer:
src/
users/
signup.ts ← validation, creation, email all here
user-store.ts ← persistence for users
types.ts ← User types
orders/
create-order.ts
order-store.ts
types.ts
products/
product-store.ts
types.tsEach directory is a cohesive unit. Adding a field to users means changing files in src/users/. The blast radius is contained.
How to Measure Locality
A simple heuristic: for any function or flow, count how many files you need to open to understand it.
| Hops | Assessment |
|---|---|
| 0-1 | Excellent. Behavior is local. |
| 2-3 | Acceptable. Some abstraction is justified. |
| 4-5 | Suspicious. Consider co-locating. |
| 6+ | Problem. You have scattered a single concern across too many files. |
This is not an absolute rule. A function that calls three deep modules is fine because each module hides its complexity. The problem is when you must read into those modules to understand the calling function.
The Balancing Act
Locality and modularity are in tension. Perfect locality means everything in one file. Perfect modularity means every concept in its own file. The right answer is somewhere in between.
Group by cohesion: things that change together belong together. A validation rule and the function that uses it are highly cohesive. A validation rule and an unrelated validation rule are weakly cohesive (they are both "validators" but that is not meaningful coupling).
Split by independence: things that can change independently belong apart. The user database schema and the email template are independent. Put them in different modules.
// High cohesion: these belong together
// - User type definition
// - User creation function
// - User validation logic
// They always change together and reference each other.
// Low cohesion: these do NOT belong together
// - User validation
// - Order validation
// - Product validation
// They never change together and never reference each other.
// Grouping them by "validator" category is false organization.Check Your Understanding
What is the prime directive of code organization?
How should you organize code?
When does separation of concerns go too far?
Practice
Consolidate a scattered signup flow into co-located modules:
Summary
- Locality of behavior: the behavior of a unit should be obvious by looking only at that unit.
- Every hop is a tax on understanding. Minimize the number of files you need to read.
- Co-locate related code. Validation, creation, and side effects for one flow belong together.
- Organize by feature, not by layer. Things that change together belong together.
- Separation of concerns can go too far. Mechanical layering scatters single features across many files.
- Measure locality: count the hops. 0-1 is excellent, 6+ is a problem.
Next, we bring everything together: strategic programming and the mindset shift from tactical to strategic.