Enterprise Architecture Patterns Tutorial 0/65 lessons ~6 min read Lesson 23

    Onion Architecture

    Onion architecture (Jeffrey Palermo) wraps the domain model in layers of interfaces — Domain Model at core, Domain Services, Application Services, Infrastructure — with all depe…

    Course progress0%
    Focus
    18 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Introduction

    Onion architecture (Jeffrey Palermo) wraps the domain model in layers of interfaces — Domain Model at core, Domain Services, Application Services, Infrastructure — with all dependencies pointing toward the center. Google's internal ads billing systems use onion-style boundaries to keep auction logic independent of Spanner, Bigtable, and gRPC transport.

    Real production story

    Google Ads' billing pipeline had auction outcome logic scattered across Spanner DAO classes and gRPC handler stubs. When Spanner migration changed timestamp semantics, auction reconciliation broke silently — domain experts could not review the fix because "domain logic" was embedded in SQL strings.

    The onion refactor placed AuctionSettlement aggregate and SettlementPolicy at the core, surrounded by IAuctionRepository and IBillingNotifier interfaces. Application services orchestrated batch settlement; infrastructure implemented Spanner repositories. Domain services held cross-aggregate rules (currency conversion) without infrastructure imports. Billing SREs could swap Spanner regions by changing infrastructure DI bindings — zero domain diffs.

    Business problem

    Business pressure: Google Ads billing must reconcile billions of micro-transactions daily with zero tolerance for silent drift while infrastructure teams migrate storage backends quarterly.

    • Revenue at risk: Settlement drift of 0.01% equals millions in advertiser disputes and regulatory scrutiny.
    • Engineering velocity: Domain experts blocked on C++ infrastructure PRs to change a rounding rule.
    • Compliance / trust: Financial audit requires domain rules readable without Spanner-specific knowledge.

    Architecture overview

    The onion has Domain Model at center, then Domain Services, Application Services, Infrastructure. Unlike layers, the onion emphasizes interface-defined seams between each ring.

    • Definition: Core domain model + interfaces; outer rings implement interfaces and call inward.
    • When to adopt: DDD-heavy systems with rich aggregates and infrastructure volatility.
    • When to defer: Read-heavy CRUD — onion rings add mapping without domain richness payoff.
    • Operability: Application services are transaction and auth boundaries; infra handles retries.

    Architecture motivation

    Why architects care: Onion architecture explicitly separates Domain Model (entities) from Domain Services (stateless domain operations) from Application Services (use case flow) — clearer than generic "domain layer" in layered architecture.

    • Force: Complex domain with both entity lifecycles and cross-cutting domain calculations.
    • Constraint: Infrastructure churn (Spanner → multi-region) must not rewrite settlement rules.
    • Outcome: Domain model persistence-ignorant; infrastructure injected at application boundary.

    Internal architecture

    Google Ads billing onion — interfaces between every ring:

    text
    ┌─────────────────────────┐
    │ INFRASTRUCTURE │
    │ SpannerRepo, PubSub, │
    │ gRPC handlers, metrics │
    └───────────┬─────────────┘
    │ implements
    ┌───────────▼─────────────┐
    │ APPLICATION SERVICES │
    │ SettleDailyAuctions │
    │ (orchestration, UoW) │
    └───────────┬─────────────┘
    │ uses
    ┌───────────▼─────────────┐
    │ DOMAIN SERVICES │
    │ CurrencyConverter │
    │ (stateless domain ops) │
    └───────────┬─────────────┘
    │ operates on
    ┌───────────▼─────────────┐
    │ DOMAIN MODEL │
    │ Auction, Settlement, │
    │ Money — NO interfaces │
    └─────────────────────────┘
    ◀── dependencies point to center ──

    Data flow

    Primary path: Cron triggers SettleDailyAuctions application service → loads unsettled auctions via IAuctionRepository → domain service converts currencies → aggregate settle() → repository persists → IBillingNotifier publishes completion event.

    • Write path: Application service owns unit-of-work; one Spanner transaction in infrastructure adapter.
    • Read path: Query repositories return domain models or read DTOs via separate query interfaces.
    • Async path: Pub/Sub adapter delivers auction events to application service; idempotent settlement keys prevent double-post.

    System design diagram

    Two diagrams show the Onion Architecture topology and the primary request/event path used in production at scale.

    Onion Architecture — system view
    Infrastructure
    Edge
    Application svc
    Core
    Domain svc
    Data
    Domain model
    Async
    High-level topology for Onion Architecture.
    Onion Architecture — request / event flow
    Batch trigger
    Ingress
    App service
    Store
    Domain svc
    Store
    Repository impl
    Emit
    Follow this path when reviewing production designs.

    Production code example

    C++ onion-style billing module — Google internal pattern simplified:

    cpp
    // domain/model/auction_settlement.h — innermost, no interfaces
    class AuctionSettlement {
    public:
    void Settle(const Money& final_bid, const Timestamp& at);
    bool IsSettled() const;
    std::vector<DomainEvent> ReleaseEvents();
    private:
    SettlementState state_;
    Money amount_;
    };
    // domain/services/currency_converter.h — domain service ring
    class CurrencyConverter {
    public:
    Money ToUsd(const Money& amount, const FxRateTable& rates) const;
    };
    // application/settle_daily_auctions.h
    class SettleDailyAuctions {
    public:
    SettleDailyAuctions(IAuctionRepository* repo, IBillingNotifier* notifier,
    CurrencyConverter converter);
    Result Run(const Date& settlement_date, const IdempotencyKey& key);
    };
    // infrastructure/spanner_auction_repository.cc — outer ring
    Status SpannerAuctionRepository::Save(AuctionSettlement& settlement) {
    // Spanner mutation; maps domain ↔ proto; retries here only
    }
    // DI wiring at composition root — infra injected into application service
    auto app = SettleDailyAuctions(
    spanner_repo.get(), pubsub_notifier.get(), CurrencyConverter{});

    Enterprise case study

    Google Ads billing settlement (2017–2019): Onion refactor enabled Spanner multi-region migration with domain code freeze — only infrastructure adapters changed.

    • Before: SQL embedded in handlers; settlement bugs required Spanner experts.
    • Decision: Onion rings with explicit repository/notifier interfaces; domain model unit tests without Spanner emulator.
    • After: Multi-region cutover in 6 weeks; zero settlement logic changes; audit praised readable domain model.

    Trade-offs

    • Onion vs hexagonal: Onion names domain vs application services explicitly; hexagonal names ports/adapters — often combined in practice.
    • Interface proliferation: Every ring crossing needs interface — discipline required to avoid single-interface-per-class absurdity.
    • ORM impedance: Persistence-ignorant domain models require explicit mapping in infrastructure — no lazy-load traps.

    Security considerations

    Security is architectural: Application services enforce advertiser authorization before domain operations; infrastructure encrypts Spanner rows.

    • Identity: gRPC auth context mapped to AdvertiserId value object at application service entry.
    • Data: PII stays in infrastructure adapters; domain uses opaque advertiser tokens.
    • Supply chain: Domain model ring has minimal dependencies — supply chain review focused on infrastructure ring.

    Scalability analysis

    Scale dimensions: Google scales application and infrastructure rings horizontally; domain model stays in-process per shard.

    • Horizontal scale: Partition settlement by advertiser_id; application service instances claim shard leases.
    • Hot spots: Mega-advertiser dominates shard — sub-partition at infrastructure without domain change.
    • Cost: Mapping layers add CPU on batch paths — acceptable vs cost of settlement errors.

    Failure scenarios

    What breaks: Domain services that secretly call repositories (infrastructure leak); application service becomes 2000-line god class.

    • Lazy load in domain: ORM proxy on entity triggers DB call during domain rule — persistence ignorance violated.
    • Duplicate domain logic: Same rule in domain service and aggregate — consolidate into aggregate behavior.
    • Settlement partial write: Spanner transaction timeout mid-batch — design idempotent settlement keys and resume.

    Staff engineer insights

    • Domain Model center must be interface-free — if you see IEntity at the core, the onion is inside-out.
    • Google staff loop: distinguish domain service (stateless calc) from application service (workflow) — conflating them is the #1 onion failure mode.
    • Onion pairs naturally with DDD aggregates — one aggregate root per consistency boundary at the domain model core.

    Interview questions

    Interview Prep

    Practice concise answers, then expand each card for the explanation.

    3 questions
    1AdvancedQuestionWhat is the difference between domain services and application services in onion architecture?+

    Answer

    Domain services hold stateless domain logic involving multiple entities or external domain concepts (currency conversion, pricing engine) — still persistence-ignorant. Application services orchestrate use cases, manage transactions, and call repositories — they coordinate but do not contain business rules that belong in entities or domain services.

    Follow-up

    Give an example where a rule belongs in domain service vs aggregate.
    2AdvancedQuestionHow does onion architecture relate to DDD?+

    Answer

    Onion is the structural expression of DDD tactical patterns — aggregates at core, repositories as infrastructure implementing domain-defined interfaces, domain services for cross-aggregate logic. Bounded contexts map to separate onions, not shared cores.

    Follow-up

    Can two bounded contexts share the domain model ring?
    3AdvancedQuestionWhy must the domain model be persistence-ignorant?+

    Answer

    So infrastructure migrations (Spanner regions, ORM swaps) never touch business rules auditors and domain experts review. Lazy loading, cascade deletes, and ORM annotations on entities couple domain to storage and hide queries inside rules — settlement bugs become infrastructure archaeology.

    Follow-up

    How do you implement lazy loading without violating onion?

    Architecture review questions

    • Is the domain model ring free of interfaces and infrastructure imports?
    • Are domain services stateless and persistence-ignorant?
    • Do application services own transactions and authorization, not business rules?
    • Are repository interfaces defined adjacent to domain, implemented only in infrastructure?
    • Does DI/composition root wire all rings without domain knowing concrete types?
    • Are aggregates the consistency boundary at the domain model core?

    Summary

    Onion architecture at Google scale keeps ads billing settlement rules in a persistence-ignorant domain model while Spanner, Pub/Sub, and gRPC live in outer infrastructure. Staff engineers place aggregates at the center, enforce interface seams between rings, and treat application services as the only orchestration and transaction boundary.

    Ready to mark this lesson complete?Track your journey across the entire course.