Skip to content

ADR 001: Nano-Service Architecture

Status: Accepted

Context

The underwrite platform models 34 distinct business domains (mechanism, risk, fraud, compliance, decision, payment, collection, NPA, collateral, recovery, governance, identity, etc.) that must coexist in a single Python process while preserving the logical separation normally associated with microservices.

The codebase is a single Python package (pyproject.toml defines the package as underwrite). All source lives under underwrite/ with services under underwrite/services/. The Core base class is at services/base.py:149.

Problem

How should these 34 business domains be structured for independent development, testability, and future deployability without the operational overhead of a distributed system?

Decision

Decompose the monolith into nano-services — lightweight Core ABC subclasses that communicate exclusively through an in-process event bus (EventBus at bus.py:426).

Each Core (services/base.py:149): - Owns exactly one domain boundary (e.g., fraud, pricing, disbursement) - Has its own Identity (Ed25519 keypair at identity.py:30) for signing emitted events - Persists state through a Store ABC (store.py:52) - Implements a single handle(event: Message) -> None method - Can be independently started via Runtime.start(["risk", "fraud"]) or underwrite run risk - Supports optional max_concurrent thread-pool dispatch for I/O-bound handlers

Cross-cutting concerns (authz, tracing, metrics, idempotency, saga, supervision) are injected transparently in Core.dispatch() and Core.handle_event() at services/base.py:353-460.

Wiring is declarative: the WIRING dict in handler.py:95 maps each Type to its subscriber list. On startup, Runtime.wire() iterates this map and subscribes each listed service.

Alternatives Considered

  • True microservices (HTTP/gRPC): Network overhead, serialization cost, and deployment complexity. Rejected because ~80% of interactions across 34 services are sub-millisecond state queries (e.g., graph_credit_limit). The nano-service model keeps them in-process with zero serialization overhead.

  • Monolithic service with internal modules: Module-level separation does not enforce an event-driven contract. Nothing prevents a fraud module from calling a pricing module's internal function, creating implicit coupling. The Core ABC enforces that the only communication path is EventBus.publish().

  • Actor model (Akka, Thespian): Over-engineered for a single-process Python system. The actor lifecycle and supervision primitives overlap with what ThreadPoolExecutor + ServiceSupervisor (supervisor.py:15) already provide.

Consequences

Positive

  • Zero serialization overhead — event dispatch is a function call through the bus
  • Synchronous execution guarantees — emit() returns after all subscribers have processed (or DLQ'd). No eventual consistency within the process.
  • Independent deployability — underwrite run risk starts only the risk service. Any service can be extracted to its own process when needed by swapping the EventBus backend.
  • Testability — services are tested in isolation by subscribing them to synthetic events
  • Auditability — every state change is a published, signed event captured by AuditService

Negative

  • No network isolation — a crash in one service takes down the entire process. Mitigated by ServiceSupervisor auto-restart with exponential backoff.
  • Single-process bottleneck — all services share the same GIL. Mitigated by ThreadPoolExecutor for concurrent handler dispatch (configurable per service via max_concurrent).
  • Runtime.runtime.py and MechanismService may grow large enough to violate SRP — split out when they cross ~500 lines, tracked in docs/REFACTORING_PLAN.md.