Back to Systems Thinking & Architecture Mastery Series

Part 5: Architecture Foundations

May 15, 2026 Wasil Zafar 30 min read

Architecture is not about choosing React or Kubernetes. It's about defining boundaries, managing constraints, navigating tradeoffs, and enabling evolution. And sometimes, the most architecturally sophisticated choice is a monolith.

Table of Contents

  1. Module 7: What is Architecture?
  2. Module 8: Monolithic Architectures
  3. Case Studies
  4. Exercises
  5. Conclusion & Next Steps

Module 7: What is Architecture?

Ask ten engineers "what is software architecture?" and you'll get twelve answers. Some will say "the high-level structure." Others will say "the important decisions." A few will say "whatever is hard to change later." All of these are partially right — and dangerously incomplete.

The IEEE 1471 standard defines architecture as: "The fundamental organization of a system, embodied in its components, their relationships to each other and the environment, and the principles governing its design and evolution." Notice what's NOT in that definition: no mention of programming languages, frameworks, databases, or cloud providers. Architecture is about relationships, constraints, principles, and evolution — not technology selection.

The Real Definition: Boundaries, Interactions, Constraints, Evolution

Architecture = the decisions that are expensive to reverse. It's the shape of the system that constrains all future decisions. Not which HTTP framework you use (that's implementation). Not which CI tool runs your tests (that's tooling). Architecture is: How does data flow between components? What can change independently? What requires coordinated change? What are the explicit tradeoffs you've accepted?

Architecture defines four things:

  1. Boundaries — Where does one component end and another begin? What is internal (can change freely) vs. external (requires coordination to change)?
  2. Interactions — How do components communicate? Synchronous? Asynchronous? Event-driven? What contracts exist between them?
  3. Constraints — What are the hard limits? Regulatory compliance, latency budgets, team size, deployment frequency, cost ceilings.
  4. Evolution paths — How will this system change over time? What dimensions of change are easy? Which are intentionally expensive?
Architecture is NOT Technology Selection — It's About Relationships
flowchart TD
    subgraph NOT["❌ Not Architecture"]
        N1["React vs Vue"]
        N2["PostgreSQL vs MySQL"]
        N3["AWS vs GCP"]
        N4["REST vs GraphQL"]
    end

    subgraph IS["✅ Architecture"]
        A1["Service boundaries"]
        A2["Data ownership rules"]
        A3["Communication patterns"]
        A4["Failure isolation"]
        A5["Evolution constraints"]
    end

    NOT --> |"Implementation
decisions"| IMPL["Easy to change
Low cost to reverse"] IS --> |"Structural
decisions"| STRUCT["Hard to change
High cost to reverse"] style N1 fill:#fff5f5,stroke:#BF092F,color:#132440 style N2 fill:#fff5f5,stroke:#BF092F,color:#132440 style N3 fill:#fff5f5,stroke:#BF092F,color:#132440 style N4 fill:#fff5f5,stroke:#BF092F,color:#132440 style A1 fill:#e8f4f4,stroke:#3B9797,color:#132440 style A2 fill:#e8f4f4,stroke:#3B9797,color:#132440 style A3 fill:#e8f4f4,stroke:#3B9797,color:#132440 style A4 fill:#e8f4f4,stroke:#3B9797,color:#132440 style A5 fill:#e8f4f4,stroke:#3B9797,color:#132440 style IMPL fill:#f0f4f8,stroke:#16476A,color:#132440 style STRUCT fill:#f0f4f8,stroke:#16476A,color:#132440

Martin Fowler puts it bluntly: "Architecture is about the important stuff. Whatever that is." The "important stuff" is context-dependent — for a high-frequency trading system, the network topology between matching engines IS architecture. For a marketing landing page, it's not. The architect's first job is identifying which decisions matter for this specific system in this specific context.

Quality Attributes: Measuring Architecture

Architecture manifests through quality attributes (also called "-ilities"). These are the measurable properties that distinguish a well-architected system from a poorly-architected one. They are NOT functional requirements (what the system does) — they are cross-cutting concerns about how well the system does it.

Quality Attribute Definition Concrete Measurement Architectural Impact
Availability System is operational when needed 99.99% uptime = 52 min downtime/year Redundancy, failover, health checks
Scalability Handle increasing load without degradation Linear cost scaling: 2× traffic = ≤2.2× cost Stateless services, sharding, async processing
Performance Response time under load p99 latency < 200ms at 10K RPS Caching layers, data locality, connection pooling
Security Protection against unauthorized access Zero critical CVEs, SOC 2 Type II certified Zero-trust, encryption at rest/transit, least privilege
Maintainability Ease of making changes Mean time to implement feature: < 3 days Modularity, clear interfaces, low coupling
Operability Ease of running in production MTTR < 15 min, automated recovery for 80% of incidents Observability, circuit breakers, graceful degradation
Testability Ease of verifying correctness Full integration test suite runs in < 10 min Dependency injection, contract testing, feature flags

The critical insight: quality attributes conflict. You cannot maximize all of them simultaneously. Optimizing for performance often hurts maintainability (caching adds complexity). Optimizing for security often hurts usability (MFA adds friction). Architecture is the art of choosing which attributes to prioritize and which to sacrifice — and documenting those tradeoffs explicitly.

# quality-attributes-spec.yaml
# Explicit quality attribute targets for an e-commerce platform
# These drive ALL architectural decisions — not technology preferences

system: order-processing-platform
version: "2.1"
date: "2026-05-15"
stakeholders:
  - product: "Sub-second checkout experience"
  - engineering: "Ship features weekly without breaking production"
  - security: "PCI DSS Level 1 compliance"
  - finance: "Infrastructure cost < 8% of revenue"

quality_attributes:
  availability:
    target: "99.95%"
    measurement: "Successful requests / total requests (5xx excluded)"
    budget: "4.38 hours downtime per year"
    priority: 1  # Highest priority — revenue-critical

  performance:
    latency_p50: "45ms"
    latency_p95: "120ms"
    latency_p99: "200ms"
    throughput: "15,000 requests/second sustained"
    measurement: "End-to-end from client request to response"
    priority: 2

  scalability:
    horizontal: true
    cost_ratio: "2x traffic = max 2.3x cost"
    scaling_time: "New instances serving traffic within 90 seconds"
    peak_handling: "5x normal load during flash sales"
    priority: 3

  security:
    compliance: ["PCI-DSS-L1", "SOC2-Type-II", "GDPR"]
    encryption: "AES-256 at rest, TLS 1.3 in transit"
    auth: "OAuth 2.0 + PKCE, session timeout 30 min"
    priority: 4

  maintainability:
    deploy_frequency: "Multiple times per day"
    lead_time: "Commit to production < 1 hour"
    change_failure_rate: "< 5%"
    priority: 5

  operability:
    mttr: "< 15 minutes for P1 incidents"
    auto_recovery: "80% of known failure modes self-heal"
    observability: "Distributed tracing on 100% of requests"
    priority: 6

tradeoffs_accepted:
  - "We accept higher infrastructure cost (scalability) to guarantee sub-200ms p99"
  - "We accept deployment complexity (security) to maintain PCI compliance"
  - "We sacrifice some maintainability (performance) by allowing strategic caching"
  - "We accept lower test coverage on edge cases to maintain deploy frequency"

Architecture Decision Records (ADRs)

Every architecture embodies hundreds of decisions. Most of them are undocumented — living only in the heads of engineers who may have already left the company. Six months later, a new engineer asks: "Why are we using event sourcing here instead of a simple CRUD database?" Nobody remembers. The code becomes a archaeological artifact — studied but never truly understood.

Architecture Decision Records (ADRs) solve this by treating architectural decisions as first-class artifacts. Each ADR documents: what was decided, why, what alternatives were considered, and what tradeoffs were accepted. They're lightweight (1-2 pages), versioned in the repository alongside code, and accumulated over time to form the system's "decision log."

ADR Lifecycle — From Proposal to Superseded
stateDiagram-v2
    [*] --> Proposed: Engineer identifies
architectural decision Proposed --> Accepted: Team reviews,
approves tradeoffs Proposed --> Rejected: Alternative chosen,
document why Accepted --> Deprecated: Context changed,
decision no longer optimal Accepted --> Superseded: New ADR replaces
with better approach Deprecated --> Superseded: Migration
completed Rejected --> [*] Superseded --> [*]
{
  "adr": {
    "id": "ADR-0042",
    "title": "Use modular monolith over microservices for order domain",
    "status": "Accepted",
    "date": "2026-05-15",
    "deciders": ["Sarah Chen (Staff Engineer)", "Marcus Webb (VP Engineering)"],
    "context": {
      "problem": "Order processing requires tight consistency guarantees across inventory, payment, and fulfillment. Our team of 8 engineers cannot sustain the operational overhead of distributed transactions across microservices.",
      "constraints": [
        "Team size: 8 engineers (4 senior, 4 mid-level)",
        "Consistency requirement: Orders must never oversell inventory",
        "Deploy target: Multiple deploys per day",
        "Budget: Cannot afford dedicated SRE team yet"
      ]
    },
    "decision": "Implement the order domain as a modular monolith with clear module boundaries enforced by ArchUnit tests. Modules communicate via in-process method calls with defined interfaces. Data isolation enforced at schema level (separate schemas, shared database).",
    "alternatives_considered": [
      {
        "option": "Microservices with saga pattern",
        "pros": ["Independent scaling", "Technology diversity", "Fault isolation"],
        "cons": ["Distributed transaction complexity", "Operational overhead for 8-person team", "Network latency on hot path"],
        "rejected_because": "Operational cost exceeds our team capacity. Saga pattern adds weeks of development for compensating transactions."
      },
      {
        "option": "Traditional layered monolith",
        "pros": ["Simple", "Fast development initially"],
        "cons": ["No module boundaries", "Becomes big ball of mud", "Cannot extract later"],
        "rejected_because": "No enforcement of boundaries means coupling will grow unchecked. We've seen this fail at scale."
      }
    ],
    "consequences": {
      "positive": [
        "Single deployment unit — simple CI/CD",
        "In-process calls — zero network latency between modules",
        "Strong consistency via database transactions",
        "Team can reason about entire system in one codebase"
      ],
      "negative": [
        "Must scale entire application together (cannot scale order processing independently)",
        "Must enforce module boundaries through discipline + automated tests",
        "Technology lock-in: all modules share same language/runtime"
      ],
      "risks": [
        "If team grows to 20+, monolith may become coordination bottleneck",
        "Module boundaries may erode without vigilant ArchUnit enforcement"
      ]
    },
    "review_date": "2027-01-15",
    "supersedes": null,
    "related_adrs": ["ADR-0038", "ADR-0039"]
  }
}

ATAM: Architecture Tradeoff Analysis Method

The Software Engineering Institute (SEI) developed ATAM as a structured process for evaluating architecture against quality attribute requirements. It's not about finding "the best architecture" — it's about surfacing tradeoffs and risks before they become production incidents.

ATAM works by identifying sensitivity points (where a small change in the architecture dramatically affects a quality attribute) and tradeoff points (where improving one quality attribute degrades another). For example:

  • Sensitivity point: The database connection pool size. Too small → requests queue and latency spikes. Too large → database runs out of connections and crashes.
  • Tradeoff point: Adding a cache layer improves performance (latency drops from 200ms to 5ms) but hurts consistency (stale data visible for TTL duration) and increases operational complexity (cache invalidation bugs).
Quality Attribute Tradeoff Analysis — You Can't Maximize Everything
flowchart TD
    ARCH["Architecture
Decision"] --> QA1["Performance ⬆️"] ARCH --> QA2["Maintainability ⬇️"] ARCH --> QA3["Security ⬆️"] ARCH --> QA4["Usability ⬇️"] QA1 --> T1["Caching adds latency benefit
but stale data risk"] QA2 --> T2["Cache invalidation logic
increases code complexity"] QA3 --> T3["Zero-trust adds protection
but authentication overhead"] QA4 --> T4["MFA reduces breach risk
but adds user friction"] T1 --> TRADEOFF["Every architectural decision
is a TRADEOFF — document it"] T2 --> TRADEOFF T3 --> TRADEOFF T4 --> TRADEOFF style ARCH fill:#e8f4f4,stroke:#3B9797,stroke-width:2px,color:#132440 style QA1 fill:#e8f4f4,stroke:#3B9797,color:#132440 style QA2 fill:#fff5f5,stroke:#BF092F,color:#132440 style QA3 fill:#e8f4f4,stroke:#3B9797,color:#132440 style QA4 fill:#fff5f5,stroke:#BF092F,color:#132440 style TRADEOFF fill:#f0f4f8,stroke:#16476A,stroke-width:2px,color:#132440

Evolution Over Perfection

The most dangerous misconception in software architecture: that you must get it right upfront. That architecture is a one-time act of genius that produces a blueprint to be followed forever. This is waterfall thinking applied to structural decisions — and it fails for the same reasons waterfall fails for features.

Good architecture is designed to evolve. It acknowledges that today's constraints will change, today's team will grow, today's traffic patterns will shift. The architect's job is not to predict the future — it's to make the system cheap to change in the directions that matter and resilient to surprises in directions that don't.

Principles for evolutionary architecture:

  • Defer decisions until the last responsible moment — don't choose a database on day 1 when you haven't seen real query patterns yet. Use repository interfaces and decide later.
  • Make boundaries explicit — even in a monolith, define module interfaces so that extraction to a service is a mechanical refactor, not an archaeological dig.
  • Use fitness functions — automated checks that verify architectural properties are maintained as code evolves (dependency direction, cycle-free modules, response time budgets).
  • Embrace "good enough" — architecture that ships and can be improved beats perfect architecture that never ships.

Module 8: Monolithic Architectures

In the era of microservices hype, admitting you run a monolith feels like confessing to using a flip phone. But here's what the industry's most successful companies know: monolithic architecture is not a failure state — it's often the optimal choice. Shopify runs a $5B+ commerce platform on a modular monolith. Basecamp serves millions on a single Rails application. Stack Overflow handled 1.3 billion page views per month with a monolithic .NET application.

A monolith is simply a system where all functionality deploys as a single unit. That's it. It says nothing about internal organization, code quality, or scalability. A well-structured monolith with clear module boundaries can be a thing of architectural beauty. A poorly-structured microservice system can be a distributed nightmare of tangled dependencies.

The Layered Monolith

The most traditional monolithic pattern: horizontal layers where each layer has a specific responsibility and can only call the layer directly below it. This was the dominant architecture for enterprise applications from the 1990s through the 2010s.

Layered Monolith — Horizontal Separation of Concerns
flowchart TD
    CLIENT["Client
(Browser / Mobile)"] --> PRES subgraph MONOLITH["Single Deployment Unit"] PRES["Presentation Layer
Controllers, Views, API Endpoints"] BIZ["Business Logic Layer
Domain Services, Rules, Workflows"] DATA["Data Access Layer
Repositories, ORM, Queries"] DB["Database
Single shared schema"] PRES --> BIZ BIZ --> DATA DATA --> DB end style CLIENT fill:#f0f4f8,stroke:#16476A,color:#132440 style PRES fill:#e8f4f4,stroke:#3B9797,color:#132440 style BIZ fill:#dff0f0,stroke:#3B9797,color:#132440 style DATA fill:#d4ebeb,stroke:#3B9797,color:#132440 style DB fill:#c9e5e5,stroke:#3B9797,color:#132440 style MONOLITH fill:#fafffe,stroke:#3B9797,stroke-width:2px,color:#132440

Layer responsibilities:

  • Presentation: HTTP handling, request/response serialization, input validation, authentication middleware. Knows about the shape of external requests but nothing about business rules.
  • Business Logic: Domain rules, workflows, calculations, orchestration. The "brain" of the system. Should be testable without HTTP or database dependencies.
  • Data Access: Translates between domain objects and database records. Repository pattern, ORM mappings, query optimization. Hides storage implementation details from business logic.
  • Database: Single shared schema. All tables accessible from any data access repository. This is both the strength (transactional consistency) and weakness (coupling) of the layered monolith.

The problem with layers: They enforce horizontal separation (by technical concern) but don't enforce vertical separation (by business domain). A "User" entity might be referenced by the Order layer, the Shipping layer, the Billing layer, and the Notification layer — creating implicit coupling across the entire codebase. Changing the User schema requires coordinating changes in every layer that touches it.

The Modular Monolith: Best of Both Worlds

A modular monolith takes the deployment simplicity of a monolith and combines it with the structural discipline of service-oriented architecture. Instead of horizontal layers, it's organized into vertical modules aligned to business domains — each module owns its data, business logic, and API surface, but all modules deploy together as a single application.

Modular Monolith — Vertical Domain Boundaries, Single Deployment
flowchart TD
    CLIENT["Client"] --> GW["API Gateway / Router"]

    subgraph MONOLITH["Single Deployment Unit"]
        subgraph MOD_ORDER["Orders Module"]
            O_API["Orders API"]
            O_BIZ["Order Logic"]
            O_DB["Orders Schema"]
            O_API --> O_BIZ --> O_DB
        end

        subgraph MOD_INV["Inventory Module"]
            I_API["Inventory API"]
            I_BIZ["Inventory Logic"]
            I_DB["Inventory Schema"]
            I_API --> I_BIZ --> I_DB
        end

        subgraph MOD_PAY["Payments Module"]
            P_API["Payments API"]
            P_BIZ["Payment Logic"]
            P_DB["Payments Schema"]
            P_API --> P_BIZ --> P_DB
        end

        GW --> O_API
        GW --> I_API
        GW --> P_API
        O_BIZ -.->|"Interface call
(not direct DB access)"| I_API O_BIZ -.->|"Interface call"| P_API end style CLIENT fill:#f0f4f8,stroke:#16476A,color:#132440 style GW fill:#f0f4f8,stroke:#16476A,color:#132440 style MONOLITH fill:#fafffe,stroke:#3B9797,stroke-width:2px,color:#132440 style MOD_ORDER fill:#e8f4f4,stroke:#3B9797,color:#132440 style MOD_INV fill:#e8f4f4,stroke:#3B9797,color:#132440 style MOD_PAY fill:#e8f4f4,stroke:#3B9797,color:#132440

Key rules of the modular monolith:

  1. Modules own their data — the Inventory module NEVER directly queries the Orders schema. It calls the Orders module's public interface instead.
  2. Communication through defined interfaces — modules expose public APIs (in-process method calls). Internal implementation is hidden behind these interfaces.
  3. No circular dependencies — if Module A depends on Module B, Module B CANNOT depend on Module A. Use events for reverse communication.
  4. Boundaries enforced by tooling — not just "agreed upon" in a meeting. ArchUnit tests, package-private visibility, or separate compilation units enforce the boundaries.
The Modular Monolith Advantage: You get microservice-level modularity (clear boundaries, independent domain models, isolated data) with monolith-level simplicity (single deployment, in-process calls, ACID transactions, one codebase to debug). When you eventually need to extract a module into a separate service, the boundary is already defined — it's a mechanical refactor, not an exploratory surgery.

When to Choose a Monolith

The industry has overcorrected toward microservices. Here's when a monolith (layered or modular) is the architecturally correct choice — not a compromise, but the optimal design for your constraints:

Condition Why Monolith Wins Microservice Cost
< 10 developers Team can hold entire system in their heads. No coordination overhead. Distributed systems complexity consumes 40%+ of small team's capacity
Early startup (pre-PMF) Requirements change daily. Refactoring one codebase is fast. Refactoring service boundaries requires rewriting inter-service contracts
Strong consistency required ACID transactions across domains. No eventual consistency bugs. Distributed transactions (saga/2PC) add weeks of complexity per flow
Performance-critical hot path In-process calls: nanoseconds. No serialization/network overhead. Each service hop adds 1-5ms latency, serialization cost, failure modes
Simple domain CRUD apps don't benefit from distribution. Complexity is not justified. Microservices add operational cost that exceeds the domain's complexity
No DevOps/SRE capacity One server, one deploy pipeline, one log stream. Kubernetes, service mesh, distributed tracing, multi-pipeline CI/CD

Strengths and Weaknesses — Honest Assessment

Monolith Strengths (Often Undervalued):
  • Simplicity of deployment: One artifact, one deploy command, one rollback
  • Easier debugging: Stack traces show the full call path. No distributed tracing needed.
  • Lower operational overhead: No service mesh, no distributed tracing infrastructure, no multi-service orchestration
  • Transactional consistency: Database ACID guarantees span all operations naturally
  • Refactoring safety: IDE-assisted refactoring across the entire codebase in one PR
  • Onboarding speed: New engineers learn one codebase, one deployment, one set of tools
Monolith Weaknesses (Real Limits):
  • Scaling limits: Must scale the entire application — can't scale just the hot component
  • Organizational coupling: Large teams stepping on each other's changes; merge conflicts grow with team size
  • Technology lock-in: All modules share the same language, runtime, and framework
  • Blast radius: A memory leak in one module crashes the entire application
  • Deploy coupling: Changing one module requires redeploying everything — and testing everything

Case Studies

Case Study Shopify — $5B+ Revenue

Shopify's Modular Monolith at Scale

Shopify processes $444 billion in GMV (2023) through a single Ruby on Rails monolith — one of the largest monolithic applications in production anywhere. But it's not a "big ball of mud." Starting in 2019, Shopify undertook a deliberate transformation into a modular monolith using a system they call "componentization."

How it works:

  • The monolith is divided into ~320 components (modules) with explicit public interfaces
  • Components declare their dependencies explicitly — undeclared dependencies are CI failures
  • A custom tool ("Packwerk") statically analyzes component boundaries and flags violations
  • Each component owns its database tables — cross-component joins are forbidden
  • Teams own components, not layers — a team responsible for "Checkout" owns the full vertical slice

Why not microservices? Shopify's engineering leadership concluded that for their domain (commerce), the tight consistency requirements between inventory, orders, and payments made distributed transactions a worse tradeoff than the scaling limitations of a monolith. Instead, they invested in making the monolith modular — getting the boundary discipline of microservices without the operational cost.

Result: 3,000+ engineers contribute to the same codebase with clear ownership boundaries, high deploy frequency, and no distributed systems debugging overhead.

Modular Monolith Ruby on Rails Packwerk Component Architecture
Case Study Basecamp — The Majestic Monolith

Basecamp's Majestic Monolith Philosophy

In 2016, DHH (creator of Ruby on Rails, CTO of Basecamp) published "The Majestic Monolith" — arguing that for most companies, a well-crafted monolith is architecturally superior to microservices. Not "good enough." Superior.

Basecamp's numbers:

  • Millions of users served by a single Rails application
  • Team of ~20 programmers maintaining the entire platform
  • Deployment: single "git push" to production multiple times per day
  • No dedicated SRE team — developers handle their own operations
  • Infrastructure cost: significantly lower than comparable microservice architectures

DHH's argument: Microservices are an organizational scaling strategy, not a technical one. If your team is small enough to coordinate effectively, the overhead of distributed systems (network failures, eventual consistency, service discovery, contract versioning) is pure cost with no benefit. The "majestic monolith" is a deliberate architectural choice — not a failure to adopt microservices.

Key principle: "If you can fit your entire team in one room and everyone can understand the full system, you don't need microservices. You need good module boundaries and testing discipline."

Majestic Monolith Ruby on Rails Small Teams DHH Philosophy

Exercises

Exercise 1: Write Your First ADR

Pick an architectural decision in your current system that is NOT documented. Maybe it's "why we use PostgreSQL instead of MongoDB" or "why the authentication service is separate from the main application." Write an ADR for it using this template:

{
  "adr": {
    "id": "ADR-XXXX",
    "title": "Your decision title here",
    "status": "Accepted",
    "date": "2026-05-15",
    "deciders": ["Your name", "Other stakeholders"],
    "context": {
      "problem": "What problem prompted this decision?",
      "constraints": [
        "What limits did you have?",
        "Team size, budget, timeline, compliance?"
      ]
    },
    "decision": "What did you decide and HOW will it be implemented?",
    "alternatives_considered": [
      {
        "option": "Alternative 1",
        "pros": ["Advantage 1", "Advantage 2"],
        "cons": ["Disadvantage 1", "Disadvantage 2"],
        "rejected_because": "Why this alternative lost"
      }
    ],
    "consequences": {
      "positive": ["Good outcome 1", "Good outcome 2"],
      "negative": ["Accepted downside 1", "Accepted downside 2"],
      "risks": ["What could go wrong?"]
    },
    "review_date": "When should this decision be re-evaluated?"
  }
}

Challenge: Share your ADR with a teammate who wasn't part of the original decision. Can they understand the "why" without asking follow-up questions? If not, your context section needs more detail.

Exercise 2: Architecture Fitness Functions

Fitness functions are automated tests that verify your architectural properties remain intact as code evolves. They run in CI and fail the build if architectural invariants are violated.

#!/bin/bash
# architecture-fitness-functions.sh
# Run these in CI to enforce architectural boundaries

echo "=== Architecture Fitness Functions ==="
echo ""

# Fitness Function 1: No circular dependencies between modules
echo "[1/4] Checking for circular dependencies..."
CYCLES=$(find src/modules -name "*.ts" -exec grep -l "import.*from.*modules/" {} \; | \
  while read file; do
    MODULE=$(echo "$file" | sed 's|src/modules/\([^/]*\)/.*|\1|')
    IMPORTS=$(grep "from.*modules/" "$file" | sed "s|.*modules/\([^/']*\).*|\1|")
    for imp in $IMPORTS; do
      if grep -r "from.*modules/$MODULE" "src/modules/$imp/" > /dev/null 2>&1; then
        echo "CYCLE: $MODULE <-> $imp"
      fi
    done
  done | sort -u)

if [ -n "$CYCLES" ]; then
  echo "❌ FAIL: Circular dependencies detected!"
  echo "$CYCLES"
  exit 1
else
  echo "✅ PASS: No circular dependencies"
fi

# Fitness Function 2: Module boundaries respected
echo ""
echo "[2/4] Checking module boundary violations..."
VIOLATIONS=$(grep -r "from.*modules/.*/internal" src/modules/ --include="*.ts" | \
  grep -v "from.*modules/$(basename $(dirname $file))/internal" 2>/dev/null)

if [ -n "$VIOLATIONS" ]; then
  echo "❌ FAIL: Modules accessing internal APIs of other modules!"
  echo "$VIOLATIONS"
  exit 1
else
  echo "✅ PASS: Module boundaries respected"
fi

# Fitness Function 3: Response time budget
echo ""
echo "[3/4] Checking API response time budget..."
P99=$(curl -s http://localhost:9090/api/v1/query \
  --data-urlencode 'query=histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))' | \
  jq -r '.data.result[0].value[1]' 2>/dev/null)

if [ -n "$P99" ] && (( $(echo "$P99 > 0.200" | bc -l) )); then
  echo "❌ FAIL: p99 latency ${P99}s exceeds 200ms budget!"
  exit 1
else
  echo "✅ PASS: p99 latency within 200ms budget (current: ${P99:-N/A}s)"
fi

# Fitness Function 4: Deployment size limit
echo ""
echo "[4/4] Checking deployment artifact size..."
ARTIFACT_SIZE=$(du -sm dist/ 2>/dev/null | cut -f1)
MAX_SIZE=150  # MB

if [ -n "$ARTIFACT_SIZE" ] && [ "$ARTIFACT_SIZE" -gt "$MAX_SIZE" ]; then
  echo "❌ FAIL: Deployment artifact ${ARTIFACT_SIZE}MB exceeds ${MAX_SIZE}MB limit!"
  exit 1
else
  echo "✅ PASS: Deployment artifact within size budget (current: ${ARTIFACT_SIZE:-N/A}MB)"
fi

echo ""
echo "=== All fitness functions passed ==="
// archunit-module-boundaries.js
// Automated enforcement of modular monolith boundaries
// Run as part of CI pipeline — fails build on boundary violation

const fs = require('fs');
const path = require('path');

const MODULES_DIR = path.join(__dirname, '../src/modules');
const RESULTS = { passed: 0, failed: 0, violations: [] };

// Rule 1: Modules can only import from other modules' public API (index.ts)
function checkPublicApiImports() {
  const modules = fs.readdirSync(MODULES_DIR);

  modules.forEach(moduleName => {
    const moduleDir = path.join(MODULES_DIR, moduleName);
    const files = getAllFiles(moduleDir, '.ts');

    files.forEach(file => {
      const content = fs.readFileSync(file, 'utf8');
      const imports = content.match(/from\s+['"]\.\.\/\.\.\/([^'"]+)['"]/g) || [];

      imports.forEach(imp => {
        const importedModule = imp.match(/\.\.\/\.\.\/([^/]+)/);
        if (importedModule && importedModule[1] !== moduleName) {
          // Cross-module import — must be from index (public API)
          if (!imp.includes(`../../${importedModule[1]}'`) &&
              !imp.includes(`../../${importedModule[1]}"`)) {
            RESULTS.violations.push({
              rule: 'public-api-only',
              file: file.replace(MODULES_DIR, ''),
              message: `Imports internal path of module "${importedModule[1]}"`
            });
            RESULTS.failed++;
          }
        }
      });
    });
  });

  RESULTS.passed++;
  console.log(`[Public API Rule] ${RESULTS.violations.length === 0 ? '✅' : '❌'}`);
}

// Rule 2: No module can have more than 5 direct dependencies
function checkDependencyFanOut() {
  const modules = fs.readdirSync(MODULES_DIR);

  modules.forEach(moduleName => {
    const moduleDir = path.join(MODULES_DIR, moduleName);
    const files = getAllFiles(moduleDir, '.ts');
    const dependencies = new Set();

    files.forEach(file => {
      const content = fs.readFileSync(file, 'utf8');
      const imports = content.match(/from\s+['"]\.\.\/\.\.\/([^/'"]+)/g) || [];
      imports.forEach(imp => {
        const dep = imp.match(/\.\.\/\.\.\/([^/'"]+)/);
        if (dep && dep[1] !== moduleName) dependencies.add(dep[1]);
      });
    });

    if (dependencies.size > 5) {
      RESULTS.violations.push({
        rule: 'max-dependencies',
        file: moduleName,
        message: `Module "${moduleName}" has ${dependencies.size} dependencies (max: 5)`
      });
      RESULTS.failed++;
    }
  });

  console.log(`[Dependency Fan-Out Rule] ${RESULTS.failed === 0 ? '✅' : '❌'}`);
}

function getAllFiles(dir, ext) {
  let results = [];
  const items = fs.readdirSync(dir, { withFileTypes: true });
  items.forEach(item => {
    const fullPath = path.join(dir, item.name);
    if (item.isDirectory()) results = results.concat(getAllFiles(fullPath, ext));
    else if (item.name.endsWith(ext)) results.push(fullPath);
  });
  return results;
}

// Execute
console.log('=== Module Boundary Enforcement ===\n');
checkPublicApiImports();
checkDependencyFanOut();

console.log(`\nResults: ${RESULTS.passed} rules passed, ${RESULTS.failed} violations`);
if (RESULTS.violations.length > 0) {
  console.log('\nViolations:');
  RESULTS.violations.forEach(v => console.log(`  ❌ [${v.rule}] ${v.file}: ${v.message}`));
  process.exit(1);
}

Conclusion & Next Steps

The two modules in this part establish the foundation for everything that follows in this series:

  • Module 7 taught you that architecture is about boundaries, interactions, constraints, and evolution — not technology choices. Quality attributes are how you measure architecture, and they always involve tradeoffs. ADRs make those tradeoffs visible and durable.
  • Module 8 taught you that monolithic architecture is a legitimate, often optimal, architectural choice — not a starting point to escape from. The modular monolith in particular gives you service-level boundaries without distributed systems complexity.

The key mental model: start with the simplest architecture that satisfies your quality attribute requirements, and evolve toward complexity only when concrete evidence demands it. Premature distribution is at least as dangerous as premature optimization — and far more expensive to reverse.

When do you need something beyond a monolith? When your team outgrows it (Conway's Law — Part 4). When your quality attributes require independent scaling. When fault isolation becomes critical. When different modules need different technology stacks. That's when you move to microservices — not because it's "modern," but because your constraints demand it.

Next in the Series

In Part 6: Microservices Architecture, we'll explore when and how to decompose systems into independently deployable services — service boundaries, inter-service communication, data ownership, and the real operational cost that nobody warns you about until it's too late.