Architecture usually enters the conversation after a system already hurts. A simple change crosses five modules, nobody knows which service owns a rule, and deployment depends on a sequence that exists only in the memory of the people who have been on the project the longest.
The problem is rarely a lack of technology. In most cases, the system has lost clear boundaries.
Useful architecture does not try to predict every future requirement. It organizes today’s decisions so that the next changes cost less and can be made safely.
Start with dependency direction
Frameworks, databases, and queues are important details, but they change more often than the product’s central rules. When business logic depends directly on those details, every technical replacement spreads across the entire codebase.
A simple boundary already helps:
export interface OrdersRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export class CancelOrder {
constructor(private readonly orders: OrdersRepository) {}
async execute(orderId: string) {
const order = await this.orders.findById(orderId);
if (!order) throw new OrderNotFoundError(orderId);
order.cancel();
await this.orders.save(order);
}
}The use case knows the persistence contract, but it does not know whether the implementation uses PostgreSQL, DynamoDB, or an in-memory store for tests. The dependency points from the detail toward the rule.
This does not mean creating an interface for every class. The goal is to protect decisions that carry business behavior and change at different rates.
Model boundaries the team recognizes
Organizing folders into controllers, services, and repositories groups technical types. Organizing them by capabilities — orders, billing, catalog, identity — shapes the system around the product.
The difference becomes clear as a feature grows. In a purely technical structure, billing code spreads across several folders. In a domain-oriented structure, related code stays close and the responsibility boundary remains visible.
A good module answers three questions without relying on separate documentation:
- Which decisions belong to it?
- Which operations does it offer to the rest of the system?
- Which data is it allowed to change?
If two modules need to edit the same table and validate the same rule, the boundary is still unresolved.
Contracts must evolve too
Every integration creates a commitment. That applies to a public API, an event published to a queue, and a function shared between packages.
Before changing a contract, classify the change:
- Additive: introduces an optional field or a new operation.
- Compatible: changes the implementation without altering observable behavior.
- Breaking: removes, renames, or changes the meaning of something that already exists.
Additive changes allow gradual migration. Breaking changes require versioning, a coexistence period, or explicit coordination between producers and consumers.
Events deserve special care. Once published, an event may have consumers you do not control. Treat its schema as an API: validate it, version it, and document its meaning, not just its shape.
Observability belongs in the design
An architectural boundary that does not appear in logs is difficult to operate. To follow an operation from the browser to the database, every layer must preserve enough context to answer:
- which request started the work;
- which user or system performed the action;
- how long each step took;
- which dependency failed;
- which service version was running.
A consistent correlation identifier, operation-level metrics, and structured logs solve more incidents than dashboards full of unrelated data.
Observability is not a step added after the architecture is complete. It is the mechanism that reveals whether the boundaries work under real load.
Choose the smallest sufficient architecture
Modularity does not mean distributing everything into services. A modular monolith is often the safer choice while the team is still learning the domain: one deployment unit, straightforward transactions, and internal boundaries that can mature over time.
Extract a service when there is a concrete operational reason, such as independent scaling, failure isolation, a security constraint, or genuine team autonomy. Without one, distribution adds networking, eventual consistency, tracing, and more failure points without solving the original problem.
The final test is direct: can an important change remain inside one module, with an explicit contract and observable impact? If so, the architecture is doing its job. If not, the next step is not necessarily a new tool. It is discovering which decision still has not found the right place.
