There’s a version of software architecture that’s about boxes and arrows — services, queues, databases, all neatly arranged in a diagram. That version is fine as a communication tool, but it misses the thing that actually matters: architecture is about the decisions you can’t easily undo.
Everything else is just implementation detail. You can refactor a function, rename a variable, swap a library. What you can’t easily undo is: the choice to make your data model event-sourced, the decision to couple two services through a shared database, the assumption baked into your API that every user belongs to exactly one organization.
The practical question I ask before any significant design choice is: how much would it cost to reverse this in six months?
The Dependency Direction Test
The cleanest heuristic I’ve found is to trace dependency direction. If module A imports from module B, then A depends on B. Changes to B can break A. That’s fine — until B becomes the kind of thing that changes for reasons A doesn’t control.
The classic mistake is letting your business logic depend on your infrastructure:
// Fragile: business logic coupled to storage implementation
class OrderService {
constructor(private db: PostgresClient) {}
async placeOrder(userId: string, items: CartItem[]) {
const user = await this.db.query('SELECT * FROM users WHERE id = $1', [userId]);
// ... business logic mixed with SQL
}
}
When you do this, swapping Postgres for something else, or even changing a column name, forces a rewrite of your business rules. The business logic should know nothing about SQL.
The corrected version points dependency arrows the other way:
// Stable: business logic depends on an interface, not an implementation
interface UserRepository {
findById(id: string): Promise<User | null>;
}
class OrderService {
constructor(private users: UserRepository) {}
async placeOrder(userId: string, items: CartItem[]) {
const user = await this.users.findById(userId);
if (!user) throw new UserNotFoundError(userId);
// pure business logic
}
}
Now OrderService depends on an interface that it defines, not on a concrete database client. Postgres, DynamoDB, or an in-memory map for tests — all plug in without touching the service.
Small Surface Area > Clever Design
The goal isn’t to anticipate every future requirement. It’s to minimize the blast radius when you’re wrong about the future.
A good architectural decision often looks boring: clear module boundaries, explicit contracts between components, data that flows in one direction. The appeal of clever architecture — event sourcing, CQRS, hexagonal layers — is that it feels like you’re buying optionality. Sometimes you are. More often, you’re buying complexity upfront for options you’ll never exercise.
The question isn’t “could this design accommodate feature X?” The question is “what is the simplest design that works today, and how expensive would it be to evolve it when feature X actually shows up?” Those are different questions with different answers.
The Real Skill
I’ve started to think that the real skill in architecture isn’t design — it’s deferral. The longer you can postpone irreversible decisions, the more information you have when you make them. YAGNI (“You Aren’t Gonna Need It”) isn’t laziness; it’s a deliberate strategy for keeping your options open.
Write the interface before you write the implementation. Let the usage pattern tell you what shape the abstraction should take. Commit to the schema only after you’ve actually used it.
The boxes-and-arrows diagram is often drawn before any code is written. That’s exactly backwards. Draw it after you’ve shipped something, to communicate what emerged — not to prescribe what will be.