Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Date: 2026-05-10
Status: Accepted
Deciders: Daniel Meza
Context and Problem Statement
Urza's Wallet launches with three product verticals (credit cards, pension funds, home loans) and a small initial team. The NerdWallet reference architecture uses a full microservices approach with 10+ independently deployed services (Content Service, Product Catalog, Offer Ingestion, Ranking, Calculator, User Profile, CIAM, Account Aggregation, Consent, Conversion Tracking, Notification). We need to decide whether to adopt that model at launch or use a simpler architecture that fits our team size, operational constraints, and Railway deployment model.
The core tension: microservices provide independent deployability and fault isolation per domain, but they impose significant operational overhead (service discovery, inter-service networking, distributed tracing, per-service CI/CD (Continuous Integration/Continuous Deployment) pipelines, multiple databases). A modular monolith provides the same codebase clarity through module boundaries while reducing operational complexity — at the cost of coupled deployability.
Decision Drivers
- Team size: Small team means low operational bandwidth. Managing 10+ Railway services, each with its own deployment pipeline, database, and observability configuration, would consume engineering time that should go toward product development.
- Traffic profile at launch: Expected launch traffic is 100–2,000 daily active users. This does not justify the infrastructure cost of microservices.
- Railway constraints: Railway's strength is simplicity. Deploying a complex microservices mesh on Railway would require service-to-service networking configuration and per-service Railway projects, negating Railway's operational simplicity advantage.
- Richards & Ford (FSA — Fundamentals of Software Architecture, Ch. 13 vs. Ch. 17): Service-based architecture is explicitly recommended for teams that need domain partitioning without the full operational overhead of microservices. "The distributed nature of microservices requires far more coordination and operational overhead than most early-stage teams can sustain." The architecture quantum for Urza's Wallet at launch is not per-domain — it is the entire platform.
- Extensibility requirement: The data model and module boundaries must accommodate n verticals. This can be achieved through internal module boundaries within a service, not by requiring separate deployable units per vertical.
Considered Options
- Full microservices — One Railway service per domain (product catalog, user profile, ingestion, notification, etc.), each with its own database.
- Service-based architecture (2 deployable units) — Next.js web app (with BFF (Backend-for-Frontend)) + Backend API Service (with internal modules), shared PostgreSQL.
- Layered monolith — Single deployable unit containing all logic, tightly layered but not modular.
- Modular monolith with future service extraction — Same as option 2 but with explicit module boundaries designed for extraction when a specific domain needs independent scaling.
Decision Outcome
Option 2: Service-based architecture with 2 deployable units is adopted for Phase 1.
The two deployable units are:
- Next.js Web App — handles SSG (Static Site Generation)/ISR (Incremental Static Regeneration)/SSR (Server-Side Rendering) rendering, Clerk session management, and acts as the BFF by aggregating API calls server-side.
- Backend API Service — Node.js/TypeScript Express app containing four internal modules: Product Comparison, User Profile, Content/CMS, and Data Ingestion control plane. Shares a single PostgreSQL database.
A third non-HTTP unit exists: the Data Ingestion Service (Railway cron job), which is a separate process but shares the same PostgreSQL database and has no public network interface.
Internal module boundaries within the Backend API Service are enforced via directory structure and import rules (no cross-module direct database access — each module owns its own repository class). These boundaries are designed for eventual extraction into separate services if a specific domain's load or team ownership warrants it.
Positive Consequences
- Operational simplicity: 2–3 Railway services instead of 10+. One shared PostgreSQL instance. One CI/CD pipeline per service. Fewer secrets, fewer health checks, fewer dashboards.
- Lower inter-service latency: The Next.js BFF calls the API over Railway's internal network (sub-millisecond). No distributed tracing complexity in Phase 1.
- Easier local development:
npm run devon two services (web + api) instead of orchestrating a Docker Compose with 10 containers. - Shared database simplifies cross-domain queries: "Which issuers have both credit cards and home loans?" is a single SQL query, not a distributed join across service APIs.
- Module boundaries preserved: Internal module structure ensures extraction is possible without a full rewrite when the team or load requires it. Kleppmann (DDIA — Designing Data-Intensive Applications, Ch. 1): "Maintainability" means future engineers can modify a system they didn't write — clear module boundaries serve this directly.
Negative Consequences / Trade-offs
- Coupled deployability: A bug in the User Profile module requires redeploying the entire Backend API Service, which also redeploys the Product Comparison module. Mitigated by short deployment times (~90 seconds on Railway) and rolling restart support.
- No fault isolation between domains: A memory leak in the Data Ingestion control plane could affect Product Comparison responses if both run in the same process. Mitigated by running the ingestion cron as a separate Railway service.
- Shared database is a scaling ceiling: When a single vertical's read throughput requires horizontal scaling, the shared PostgreSQL becomes a bottleneck before service separation does. Mitigation: introduce read replicas on Railway first; extract the high-throughput vertical to its own service+database when read replicas are insufficient.
- Risk of module boundary erosion: Without enforcement, developers may add cross-module imports, undermining the designed separation. Mitigation: ESLint import rules enforcing module boundaries, enforced in CI.
Pros and Cons of the Options
| Option | Pros | Cons |
|---|---|---|
| Full microservices | True fault isolation; independent deployability per domain | 10x operational overhead; premature for launch traffic; Railway not designed for service meshes |
| Service-based (chosen) | Low ops; module clarity; extractable later; shared DB simplifies queries | Coupled deploy; shared DB scaling ceiling; module drift risk |
| Layered monolith | Simplest structure | No module boundaries; extraction later is a full rewrite |
| Modular monolith (single deploy) | Maximum simplicity | Web and API coupled to the same deploy; can't scale independently |
References
- Richards & Ford, Fundamentals of Software Architecture, Ch. 13 (Service-Based Architecture), Ch. 17 (Microservices Architecture), Ch. 18 (Choosing the Appropriate Style)
- Kleppmann, Designing Data-Intensive Applications, Ch. 1 (Reliability, Scalability, Maintainability)
- NerdWallet reference architecture research —
Notes/Research/nerdwallet-architecture.md - Architecture reference §1 (Executive Summary), §3 (Container Diagram)