Architecture as a constraint-satisfaction problem
Software architecture is often presented as a quest for the "best" pattern—a universal template for success. In our experience across dozens of high-stakes projects, this is a misleading and unhelpful premise. The most successful systems are not built on dogma, but on a deep understanding of constraints.
Modern software architecture is a discipline of trade-offs, a process of satisfying a unique set of competing constraints. The right architecture for a venture-backed startup trying to find product-market fit with a team of five is fundamentally different from that of a multinational bank processing millions of transactions per hour.
The art and science lie in identifying, prioritising, and designing for these constraints. They typically fall into several categories:
- Business Constraints: What is the budget? What is the required time to market for the initial version and for subsequent features? What are the compliance and regulatory requirements (e.g., GDPR, financial auditing)?
- Organisational Constraints: What is the size, structure, and experience level of the engineering team? How many teams will work on the system? How will they be organised? A system designed for a single team of senior engineers looks very different from one supporting ten federated teams.
- Quality Attribute Constraints (The "-ilities"): What are the non-negotiable targets for reliability, performance, and security? "The system must be fast" is not a requirement; "the 99th percentile latency for the
POST /ordersendpoint must be below 250ms at 5,000 requests per minute" is. - Technical Constraints: What is the existing technology landscape? Are there legacy systems to integrate with? Are there specific platform or vendor commitments already in place?
The architecture is the set of significant decisions that shape a system, where significance is measured by the cost of change. The role of the architect is to find a solution that fits within this multi-dimensional space of constraints, not to chase a theoretical ideal. Acknowledging this from the outset prevents cargo-culting patterns from large tech companies whose constraints bear no resemblance to your own.
Modular monolith versus microservices in 2026
The debate between monoliths and microservices has matured. The community has collectively learned that starting with a distributed system from day one is often a catastrophic and expensive mistake. In 2026, the most pragmatic starting point for the vast majority of new products is the modular monolith.
A monolith is a single, unified application. All its code is in one repository, it is built as a single artefact, and it is deployed as a single process (or a set of identical processes for scaling). A microservices architecture decomposes the system into a collection of small, independent services, each with its own codebase, build process, and deployment lifecycle.
The modular monolith is not a compromise, but a strategic choice. It is a monolithic application designed with strong internal boundaries, much like the bulkheads in a ship. These modules are logically distinct parts of the codebase (e.g., Billing, Inventory, Notifications) that communicate through well-defined, stable internal APIs, not by reaching into each other's data structures.
This approach provides the primary benefit of a monolith—simplicity of development, testing, and deployment—while creating the option to physically separate modules into true microservices later. This extraction becomes a targeted, tactical decision driven by specific scaling pressures (team or technical), rather than a massive, all-or-nothing rewrite.
Here is how the two approaches compare on key decision axes:
| Factor | Modular Monolith | Microservices |
|---|---|---|
| Initial Velocity | High. Single codebase, IDE, and debug loop. | Low. Requires upfront investment in service discovery, CI/CD for N services, RPC, monitoring. |
| Operational Overhead | Low. One thing to build, deploy, and monitor. | High. A fleet of services to manage, each with its own operational concerns. |
| Team Scaling | Good up to ~10-15 engineers. Becomes a bottleneck beyond that. | Excellent for large, federated teams. Enables team autonomy. |
| Technical Scaling | Can be scaled vertically (more powerful machines) and horizontally (more instances). Hotspots can be hard to scale independently. | Excellent. Individual services can be scaled independently based on their specific load. |
| Fault Tolerance | Low. A critical bug in one module can bring down the entire application. | High. Failure is isolated to a single service (if designed correctly). |
| Data Consistency | High. Simple to achieve with local ACID transactions. | Complex. Requires patterns like sagas or eventual consistency to manage data across services. |
Worked Example: The Series A Logistics Platform
We worked with a European logistics platform that had just closed its Series A funding round. They had a team of six engineers and a mandate to build and launch a new freight-matching product within seven months.
An early proposal considered a full microservices architecture with 10+ services for users, authentication, freight listings, bidding, payments, etc. We advised against this. Building out the necessary infrastructure, CI/CD pipelines, and inter-service communication patterns would have consumed at least three months of the seven-month timeline and required hiring a dedicated platform engineer.
Instead, we recommended a modular monolith built on a clean, layered architecture. The core domain logic was organised into distinct modules (Listings, Bids, Accounts). A single PostgreSQL database was used, but schemas were segregated to enforce module boundaries.
- Estimated cost with microservices: 8 engineers (including a platform specialist) for 8 months = ~€832,000 (assuming a blended rate of €130,000/year/engineer). This doesn't include the opportunity cost of a delayed launch.
- Actual cost with modular monolith: 6 engineers for 7 months = ~€455,000.
The product launched on time and found market traction. Eighteen months later, the Bidding module was experiencing disproportionately high load and required a specialised data model. By then, the team had grown, and the business could justify the cost of extracting Bids into its own service. Because the monolith was already well-modularised, this was a planned, predictable project, not a frantic rescue mission. This is a common pattern we see in successful SaaS Development: From Idea to Global Product.
Event-driven patterns and when they pay
Event-driven architecture (EDA) is a powerful pattern for building decoupled, scalable, and resilient systems. However, it introduces significant complexity and is not a universal solution. It pays to be deliberate about when and where to use it.
In an EDA, communication between components (or services) happens asynchronously via events. An "event" is a record of something that happened, like OrderPlaced or PaymentProcessed.
- Producers publish events to a channel without knowing who, if anyone, is listening.
- Event Broker (e.g., Apache Kafka, RabbitMQ, AWS SQS/SNS) is the middleware that receives events and delivers them to consumers.
- Consumers subscribe to event channels and react to events as they arrive.
This decouples producers from consumers. The Order service doesn't need to know about the Notifications service or the Shipping service; it simply emits an OrderPlaced event. This allows new consumers to be added later without changing the existing services.
┌──────────────────────────┐
│ Event Broker (e.g. Kafka)│
└──────────┬───┬───────────┘
│ │
┌─────────────────┐ (Event) │ │ (Event)
[Client] ────> │ Order Service │ ────────> │ │ <────────── [Fraud Service]
└─────────────────┘ │ │
│ │
┌─────────▼───▼──────────┐
│ Topic: orders.placed │
└─────────┬───┬──────────┘
│ │
┌────────────────────────┘ └────────────────────────┐
│ │
┌─────────▼─────────┐ ┌─────────────▼─────────┐
│Shipping Consumer │ │ Notifications Consumer│
│(Updates shipping) │ │ (Sends email/SMS) │
└───────────────────┘ └───────────────────────┘
The benefits are clear:
- Decoupling: Services are independent. The
Notificationsservice can be down for maintenance without stopping new orders from being placed. - Scalability: Different parts of the system can be scaled independently. If you have a surge in notifications, you can scale up just the
Notificationsconsumers. - Resilience: The event broker acts as a buffer. If a consumer service fails, events are retained in the broker and can be processed when the service recovers.
However, EDA introduces its own challenges:
- Complexity: You've replaced simple, direct function calls with an entire distributed system for messaging. This requires robust monitoring to understand the flow of events.
- Eventual Consistency: Data across the system is no longer updated in a single transaction. It can take time for an event to propagate and be processed, meaning the system is temporarily in an inconsistent state. Debugging issues related to timing or event ordering can be extremely difficult.
- Developer Experience: Reasoning about a distributed, asynchronous flow is harder than reading synchronous code. Tracing a single user request across multiple event handlers is a non-trivial task.
In our practice, we recommend EDA not as a top-level architecture for an entire system, but as a tactical pattern for specific parts of a system where decoupling and asynchronous processing provide clear value. It's an excellent choice for cross-cutting concerns like analytics, auditing, or for integrating bounded contexts that need to react to each other's state changes without being tightly coupled. Our data engineering teams often use EDA to build robust data pipelines that are resilient to failures in upstream or downstream systems.
Data boundaries and the distributed-transaction trap
When you move from a monolith to any form of distributed system, the single biggest challenge is data. In a monolith, you have a single database and the power of ACID (Atomicity, Consistency, Isolation, Durability) transactions. You can update a user's profile, their latest order, and their loyalty points in a single, all-or-nothing operation.
In a distributed system, this safety net is gone. The User service, Order service, and Loyalty service each own their own database. Trying to orchestrate a transaction across these three services—a distributed transaction—is a well-known trap. They are slow, brittle, and create tight coupling, negating many of the benefits of a service-oriented approach. We would never recommend relying on two-phase commit (2PC) for business logic in a modern scalable system.
The solution is to embrace the distributed nature of the system and design for it.
-
Define Clear Bounded Contexts: This concept from Domain-Driven Design is crucial. Each service must have a clear "bounded context"—a well-defined area of the business domain it is responsible for. It owns its data exclusively. No other service is allowed to access its database directly. Communication happens only through its public API or by emitting events.
-
Use Asynchronous Patterns for Cross-Service Consistency: Instead of trying to achieve immediate consistency, you design for eventual consistency using patterns like the Saga. A Saga is a sequence of local transactions. Each local transaction updates the database in a single service and then publishes an event (or calls the next service in the chain) to trigger the next step.
- Example (Saga): Placing an order might involve:
- Order Service: Creates an
OrderinPENDINGstate (local transaction), publishesOrderCreatedevent. - Payment Service: Consumes
OrderCreated, attempts to charge the customer. PublishesPaymentSucceededorPaymentFailed. - Order Service: Consumes payment events. On
PaymentSucceeded, it updates the order state toCONFIRMED. OnPaymentFailed, it updates the state toCANCELLED.
- Order Service: Creates an
If a step fails (e.g., payment), you execute compensating transactions to undo the preceding steps (e.g., release reserved inventory). This is complex, but it's an explicit and manageable complexity, unlike the implicit, operational complexity of distributed transactions. A deep understanding of these patterns is a core component of our software engineering discipline.
- Example (Saga): Placing an order might involve:
Reliability: failure domains, degradation, and recovery targets
Building a reliable system is not about preventing failure—it's about accepting that failure will happen and architecting the system to survive it. Thinking about reliability requires moving beyond simple uptime metrics and focusing on failure domains, graceful degradation, and business-oriented recovery targets.
Failure Domains: A failure domain is a part of the system that can fail as a single unit. A key goal of architecture is to create boundaries that contain failures. A poorly designed monolith has a single, large failure domain: an unhandled exception in the image thumbnailing code can bring down the entire application.
Microservices or modular deployments (e.g., different modules as separate container groups) create smaller failure domains. If the Recommendation service fails, it shouldn't prevent a user from logging in and buying a product. The blast radius is contained. When designing, you should constantly ask: "If this component fails, what else is affected?"
Graceful Degradation: A system shouldn't be either 100% working or 100% broken. Graceful degradation is the practice of designing the system to provide a reduced but still functional service when a dependency is unavailable.
- Example: An e-commerce product page displays the product details, user reviews, and personalised recommendations. The product details are essential. The reviews and recommendations are enhancements.
- If the
Productservice is down, the page cannot be rendered. This is a hard dependency. - If the
Reviewsservice is down, the page should still render without the reviews section, perhaps with a message like "Reviews are temporarily unavailable." - If the
Recommendationservice times out, the page should render without recommendations. This is achieved using patterns like circuit breakers and timeouts for all network calls to non-essential dependencies.
- If the
Recovery Targets (RPO/RTO): These are the two most important metrics for disaster recovery, and they should be defined by the business, not by engineering.
- RTO (Recovery Time Objective): How quickly must the service be restored after a disaster? This defines your operational readiness and deployment automation requirements. An RTO of 8 hours might be acceptable for an internal admin panel, but a customer-facing payment system might require an RTO of 5 minutes.
- RPO (Recovery Point Objective): How much data can be lost? This dictates your data backup and replication strategy. An RPO of 24 hours means a daily backup is sufficient. An RPO of zero requires synchronous replication to a hot standby, a much more expensive and complex architecture.
Defining RTO and RPO for each part of your system turns a vague goal ("make it reliable") into a set of concrete, testable architectural requirements.
Performance and cost as architectural requirements
Performance and cost are not afterthoughts; they are two sides of the same coin and must be designed for from the beginning. Just as with reliability, vague goals like "fast" and "cheap" are useless. They must be quantified and treated as first-class architectural constraints.
For performance, define Service Level Objectives (SLOs) for key user journeys. For example:
- Latency: The 95th percentile API response time for
GET /products/{id}must be under 150ms. - Throughput: The system must handle 2,000 order submissions per minute.
- Availability: The login service must have 99.95% uptime, measured over a rolling 30-day window.
These SLOs directly inform architectural choices. An SLO of 150ms might preclude certain complex database queries or require an aggressive caching strategy. An SLO of 99.95% (about 22 minutes of downtime per month) requires a multi-instance, fault-tolerant deployment.
Cost is the other side of this equation. Every architectural decision has a cost implication, both in development effort and operational expenditure. For guidance on budgeting, our article on how much custom software development costs provides a useful framework.
Worked Example: The Insurer's Claims Processing Workload
A European insurer needed to build a system to re-process insurance claims in batches. The workload was extremely spiky: it would run for two hours every night, processing millions of claims, and then sit idle for the rest of the day.
They considered two architectural options:
- Provisioned Kubernetes Cluster: A cluster of virtual machines sized to handle the peak load.
- Serverless Functions (AWS Lambda): A fully event-driven architecture where each claim is processed by a short-lived function.
We modelled the cost of both approaches based on their projected 2026 workload.
| Metric | Provisioned Kubernetes Cluster (m6i.xlarge nodes) | Serverless Functions (AWS Lambda) |
|---|---|---|
| Workload | 5 million claims/night, 2h duration | 5 million claims/night, 2h duration |
| Base Monthly Cost (Idle) | ~€1,800 (for 3 reserved nodes + control plane) | ~€0 |
| Cost per Run (2 hours at peak) | Included in base cost (assuming nodes are paid for) | ~€250 (5M invocations x 500ms @ 1GB memory) |
| Total Monthly Cost (22 runs) | ~€1,800 | ~€5,500 (€250 x 22) |
| Development & Ops Complexity | Medium (managing cluster, deployments, scaling) | High (EDA complexity, distributed debugging) |
In this specific case, the predictable workload made the provisioned cluster significantly cheaper, even though it was mostly idle. The serverless approach offered better scaling elasticity, but at a much higher operational cost for this type of batch workload. The insurer chose the Kubernetes approach, but the key takeaway is that the decision was made based on a quantitative model, not on which technology was more "modern".
Documenting decisions with ADRs
An architecture is the set of decisions made to satisfy constraints. For any system that will live longer than a few months, it is vital to document why those decisions were made. The best tool we have found for this is the Architecture Decision Record (ADR).
An ADR is a short, plain-text file that documents a single significant architectural decision. Each ADR is numbered, has a status (e.g., Proposed, Accepted, Superseded), and follows a simple template.
- Title: A short, descriptive title (e.g., "001: Use PostgreSQL for primary data storage").
- Context: What is the problem we are trying to solve? What are the constraints and forces at play? This section describes the "why."
- Decision: What is the change we are proposing? Be specific and clear.
- Consequences: What is the result of this decision? What are the positive, negative, and neutral outcomes? What are the trade-offs we are accepting? This is the most important section. It demonstrates that the team has thought through the implications of the decision.
ADRs are stored in the same source control repository as the code. This makes them accessible to the entire team and provides a historical record that is invaluable for new joiners and for future architectural evolution. When someone asks, "Why on earth did we decide to use RabbitMQ instead of Kafka?", you don't have to rely on memory; you can point them to doc/adr/007-use-rabbitmq-for-internal-task-queue.md.
This practice is fundamental to how we build sustainable systems and a key part of our approach to legacy software modernization, where understanding past decisions is the first step to making better ones.
Reviewing an architecture in one day
Can you meaningfully assess a complex software architecture in a single day? Not completely, but you can identify the most significant risks and opportunities. A compressed, high-impact review is a powerful tool for de-risking a project or preparing for a period of rapid growth.
Our one-day architecture review follows a structured process, detailed in how we work, but it follows a general pattern:
- Pre-reading (before the day): We review all available documentation: existing diagrams, ADRs, business requirements, and key sections of the codebase. The goal is to arrive with context and a set of preliminary questions.
- Morning Session: The 'What' and 'Why' (2-3 hours): This is a workshop with the key technical and product stakeholders (CTO, lead architects, product manager). We don't start with technology. We start with the business.
- What are the business goals for the next 12-24 months?
- What are the key user journeys?
- What are the agreed RTO/RPO and performance SLOs?
- What is the team structure and how is it expected to evolve?
- Afternoon Session: The 'How' (3-4 hours): This is a technical deep dive with the engineering team. We whiteboard the architecture, tracing 2-3 of the most critical user journeys through the system. We focus on:
- Data Flow and Boundaries: Where does data live? How is it kept consistent?
- Failure Modes: What happens when service X or database Y fails?
- Deployment and Operability: How is a change deployed? How is the system monitored?
- Security: How are authentication, authorisation, and data security handled?
- End of Day: Synthesis and Preliminary Findings (1 hour): We conclude with a verbal summary of our initial observations, presented to the whole group. We frame this as "what we see, what we think, what we wonder." It's a chance to validate our understanding and have an open discussion.
A formal report follows, but the value of the day itself is in creating a shared, business-aligned understanding of the architecture's strengths, weaknesses, and alignment with future goals.
Frequently asked questions
Should we start with microservices?
Rarely. A well-modularised monolith with clear internal boundaries is faster to build and easier to split later, once team size and deployment friction justify separation. This "modular monolith" approach gives you the development speed of a single application while forcing the discipline of clean interfaces. It avoids the immense upfront cost and complexity of building a distributed system before you've even validated your product. You can treat the extraction of a service as a future optimisation, not a day-one requirement.
Key takeaways
- Software architecture is a constraint-satisfaction problem, not a search for a single "best" pattern. Business, team, and quality goals are the primary drivers.
- Start with a modular monolith. The simplicity of development and deployment in the early stages far outweighs the theoretical benefits of microservices, which can be extracted later if and when needed.
- Event-driven architecture is a powerful tool for decoupling and resilience, but it adds significant complexity. Use it tactically for specific use cases, not as a default for your entire system.
- Embrace eventual consistency in distributed systems. Avoid the distributed transaction trap by defining clear data ownership and using patterns like Sagas to manage workflows across services.
- Define reliability and performance with concrete, numeric targets (RTO/RPO, SLOs) that are driven by business requirements. These metrics directly inform architectural trade-offs and cost.
- Document your key decisions and their trade-offs using Architecture Decision Records (ADRs). This creates a living history that is invaluable for the long-term health of the system.
A well-designed architecture is a critical business asset that enables speed, scale, and stability. A poorly designed one creates a constant drag on an organisation's ability to execute. If your current architecture is creating friction, or you're about to embark on a new project and want to start on the right footing, an external assessment can provide a clear path forward.

