Master Software Architecture: From Simplicity to Complexity • Maciej «MJ» Jedrzejewski • GOTO 2025
This presentation by Maciej Jedrzejewski explores the concept of evolutionary software architecture, arguing that architecture is not a static, upfront design but a continuous process. It delves into the cognitive biases that lead to over-engineering, emphasizes the critical role of context, and provides a practical four-step model for evolving systems from simplicity to complexity.
Software architecture is not about creating a perfect design upfront; it’s a continuous timeline of decisions where the primary goal is to preserve possibilities for as long as possible. The most crucial decisions are often “hard to make and harder to change,” as they become deeply embedded in data models, team structures, and organizational processes.
Cognitive Traps in Architecture
Our brains’ shortcuts, while useful for survival, can lead to architectural traps. We often jump to solutions before fully understanding the problem.
- Cognitive Shortcuts & Fear of Missing Out (FOMO): We hear about technologies like microservices or event sourcing at conferences and feel our existing systems are inadequate. This leads to dangerous logic, such as “If it’s good enough for Google, it’s great for us,” without considering the context.
- Over-Engineering: Adopting shiny new tools means adopting all the problems that come with them—learning curves, operational complexity, and debugging challenges. This results in over-engineered systems where new team members take months to become productive, simple changes are difficult, and knowledge becomes siloed among a few experts. The “perfect design” is a myth; the most important factor is your specific context.
Context is King: Discovering Your Domain
The “Project Paradox” dictates that we must make the most impactful architectural decisions when we know the least about our problem, users, and scale. To counter this, one must either delay architectural choices or learn faster. The foundation of good architecture is a deep understanding of the business domain.
Techniques for Domain Discovery
- Event Storming: This collaborative technique focuses on what actually happens in the system by mapping out business-significant events (e.g.,
Contract Signed). It forces stakeholders to think chronologically and helps uncover processes and boundaries. - Domain Storytelling: Domain experts narrate a process as a story, explaining the actors, their actions, and the objects involved. This helps model both the current (“as-is”) and desired (“to-be”) states of a process.
- Bounded Context Canvas: A tool from Domain-Driven Design (DDD) used to formally describe a module or “bounded context.” It defines its purpose, strategic importance (core, supporting, generic), its unique “ubiquitous language,” and its communication patterns (inbound and outbound).
- Context Maps: These diagrams visualize the relationships between different bounded contexts and external systems, clarifying dependencies and communication patterns (e.g., upstream/downstream).
Remember, these are tools, not a rigid method. The best approach is the one that fits your unique context, team, and constraints.
The Four Steps of System Evolution
Instead of architecting for a hypothetical future, architect for the system you need to build this quarter. A system should be allowed to evolve naturally through phases.
1. Simplicity
- Goal: Deliver value quickly while laying a foundation for future extensions. The architecture should be as simple as possible while fulfilling current requirements.
- Example: For a new application serving a few thousand users, start with a modular monolith. Use packages or namespaces to create logical modules based on bounded contexts. Use a single physical database but with separate schemas for each module to enforce logical separation from day one.
- Architectural Drivers: High performance, high maintainability, low complexity.
2. Maintainability
- Trigger: The product is validated, and you’re learning from real users. You spend more time fighting the system than adding features. Some modules have grown rapidly while others are untouched.
- Action: Address the pain points. If communication overhead increases, split the team and assign ownership of modules. Extract modules into separate projects if teams are stepping on each other’s toes. Introduce patterns like the inbox/outbox pattern to handle asynchronous communication more robustly than an in-memory queue.
- Architectural Drivers: Performance and maintainability have decreased, while complexity has increased. The focus is on improving maintainability.
3. Growth
- Trigger: The system is successful but is straining under significantly increased traffic. Performance is degrading, and infrastructure costs are becoming overwhelming.
- Action: Now you can justify the added complexity of scaling. Extract the most resource-intensive modules into separate microservices that can be scaled independently. Optimize the database for reads by adding replicas. Introduce an external message broker to handle communication between the monolith and the new microservice.
- Architectural Drivers: The primary focus is on restoring performance and managing costs.
4. Complexity
- Trigger: The system is stable and performant, but a new problem has emerged: intricate business logic. New regulations or complex business requirements make the initial, simple domain model insufficient.
- Action: The focus shifts from scaling to managing business complexity. This is the time to redesign the core domain of the complex module (e.g., “Drug Prescription”). Introduce advanced DDD patterns like Aggregates to guard consistency boundaries and manage complex business rules that simple CRUD operations cannot handle.
It’s crucial to identify which phase your application is actually in, not the one you wish it were in. Not all applications will go through all phases.
Lessons from the Trenches
- Premature Caching: Don’t add a cache (e.g., Redis) to solve performance issues before optimizing database queries and considering read replicas. Otherwise, you solve nothing and add invalidation nightmares.
- Unnecessary Microservices: Avoid building a microservices architecture for an application with a tiny user base. The speaker shared an anecdote of having “more services than users.”
- The Coupled “Modular” Monolith: A modular monolith where modules are tightly coupled is just a “spaghetti” monolith, giving you the complexity of a distributed system with none of the benefits.
- Excessive Logging: Logging everything in production (debug, trace, etc.) can lead to logging bills higher than compute bills and creates so much noise that finding actual problems becomes impossible.
The best architecture is the one that solves the problems you actually have, not the ones you think you might have in the future.