Skip to content

Commit dfac067

Browse files
committed
Phase 3c : service Customers (capacité net-new moderne) + database-per-service
1 parent d446934 commit dfac067

29 files changed

Lines changed: 984 additions & 47 deletions

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The interesting decisions are written down as ADRs, not buried in commits:
2424
- [ADR-0003 — YARP API gateway](docs/adr/0003-yarp-api-gateway.md)
2525
- [ADR-0004 — RabbitMQ event-driven messaging](docs/adr/0004-rabbitmq-eventing.md)
2626
- [ADR-0005 — SignalR real-time dashboard](docs/adr/0005-signalr-realtime.md)
27+
- [ADR-0006 — Database-per-service on a shared PostgreSQL server](docs/adr/0006-database-per-service.md)
2728

2829
Full picture: [docs/architecture.md](docs/architecture.md).
2930

@@ -48,7 +49,7 @@ Full picture: [docs/architecture.md](docs/architecture.md).
4849
| 0 | Foundations (repo, docs, ADRs, compose, CI) | ✅ done |
4950
| 1 | Legacy core: COBOL batch + data, containerized | ✅ runs today |
5051
| 2 | The bridge: COBOL → JSON over HTTP | ✅ done |
51-
| 3 | Microservices behind the gateway | 🟡 in progress (3a: gateway, 3b: accounts + strangler switch) |
52+
| 3 | Microservices behind the gateway | 🟡 in progress (3a: gateway, 3b: accounts + strangler switch, 3c: customers) |
5253
| 4 | Event-driven + real-time dashboard | ⬜ planned |
5354
| 5 | CI/CD hardening, observability, tests | ⬜ planned |
5455

@@ -169,6 +170,40 @@ network.
169170
dotnet test src/CobraBridge.sln
170171
```
171172

173+
## Run the customers service (a net-new capability)
174+
175+
Phase 3c adds **CustomersService**: customer profiles with KYC status. This
176+
one isn't a migration — the COBOL core never had a notion of "customer" or
177+
KYC at all, so there's nothing to strangle. It's a modern capability the
178+
mainframe simply couldn't offer, built straight on PostgreSQL behind the
179+
gateway, no legacy source, no switch.
180+
181+
It follows database-per-service (see
182+
[ADR-0006](docs/adr/0006-database-per-service.md)): same Postgres server as
183+
AccountsService, separate database (`cobrabridge_customers` vs.
184+
`cobrabridge_accounts`).
185+
186+
```bash
187+
# locally
188+
dotnet run --project src/CobraBridge.CustomersService
189+
190+
# or as part of the full stack
191+
docker compose up -d legacy-core postgres bridge accounts-service customers-service gateway
192+
```
193+
194+
```bash
195+
curl http://localhost:8090/api/customers
196+
curl http://localhost:8090/api/customers/CUST000003
197+
curl "http://localhost:8090/api/customers?kycStatus=Verified"
198+
```
199+
200+
`customers-service` has no published port either — reached only through the
201+
gateway, like every other internal service.
202+
203+
```bash
204+
dotnet test src/CobraBridge.sln
205+
```
206+
172207
## Repository layout
173208

174209
```

docker-compose.yml

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# CobraBridge — local orchestration.
2-
# Phase 1 (legacy-core), Phase 2 (bridge), and Phase 3a/b (gateway, postgres,
3-
# accounts-service, Strangler switch) run today. The gateway is the only
4-
# service with a published port: everything else is reachable only on the
5-
# internal compose network. Later phases are scaffolded and commented until
6-
# their services exist, so `docker compose up` always works.
2+
# Phase 1 (legacy-core), Phase 2 (bridge), and Phase 3a/b/c (gateway,
3+
# postgres, accounts-service, Strangler switch, customers-service) run
4+
# today. The gateway is the only service with a published port: everything
5+
# else is reachable only on the internal compose network. Later phases are
6+
# scaffolded and commented until their services exist, so
7+
# `docker compose up` always works.
78

89
services:
910
legacy-core:
@@ -30,22 +31,26 @@ services:
3031
depends_on:
3132
- legacy-core
3233

33-
# --- Phase 3b: PostgreSQL for the modern AccountsService ---
34+
# --- Phase 3b/c: PostgreSQL, shared server, one database per service ---
35+
# (see docs/architecture.md and ADR-0006). POSTGRES_DB creates
36+
# "cobrabridge_accounts"; postgres/init/01-create-customers-db.sql creates
37+
# "cobrabridge_customers" the first time the data volume initializes.
3438
postgres:
3539
image: postgres:16
3640
container_name: cobrabridge-postgres
3741
environment:
3842
# Dev-only default credentials — never used outside this local/demo
3943
# stack. Override via POSTGRES_PASSWORD if that ever matters here.
40-
- POSTGRES_DB=cobrabridge
44+
- POSTGRES_DB=cobrabridge_accounts
4145
- POSTGRES_USER=cobrabridge
4246
- POSTGRES_PASSWORD=cobrabridge_dev_only_change_me
4347
volumes:
4448
- postgres-data:/var/lib/postgresql/data
45-
# No published port: internal-only, reached by accounts-service via the
46-
# "postgres" service name on the compose network.
49+
- ./postgres/init:/docker-entrypoint-initdb.d:ro
50+
# No published port: internal-only, reached by accounts-service and
51+
# customers-service via the "postgres" service name on the compose network.
4752
healthcheck:
48-
test: ["CMD-SHELL", "pg_isready -U cobrabridge -d cobrabridge"]
53+
test: ["CMD-SHELL", "pg_isready -U cobrabridge"]
4954
interval: 5s
5055
timeout: 5s
5156
retries: 10
@@ -58,7 +63,7 @@ services:
5863
image: cobrabridge-accounts-service
5964
container_name: cobrabridge-accounts-service
6065
environment:
61-
- ConnectionStrings__Accounts=Host=postgres;Port=5432;Database=cobrabridge;Username=cobrabridge;Password=cobrabridge_dev_only_change_me
66+
- ConnectionStrings__Accounts=Host=postgres;Port=5432;Database=cobrabridge_accounts;Username=cobrabridge;Password=cobrabridge_dev_only_change_me
6267
- Legacy__AccountsFile=/legacy/data/ACCOUNTS.DAT
6368
volumes:
6469
# read-only access to the legacy master, for the one-time seed migration
@@ -71,12 +76,28 @@ services:
7176
legacy-core:
7277
condition: service_completed_successfully
7378

74-
# --- Phase 3a/b: API gateway (YARP) — the single public entry point ---
79+
# --- Phase 3c: the Customers service (PostgreSQL-backed, net-new) ---
80+
customers-service:
81+
build:
82+
context: ./src/CobraBridge.CustomersService
83+
image: cobrabridge-customers-service
84+
container_name: cobrabridge-customers-service
85+
environment:
86+
- ConnectionStrings__Customers=Host=postgres;Port=5432;Database=cobrabridge_customers;Username=cobrabridge;Password=cobrabridge_dev_only_change_me
87+
# No published port: internal-only, reached by the gateway via the
88+
# "customers-service" service name on the compose network.
89+
depends_on:
90+
postgres:
91+
condition: service_healthy
92+
93+
# --- Phase 3a/b/c: API gateway (YARP) — the single public entry point ---
7594
# AccountsSource picks which backend answers /api/accounts: "legacy" (the
7695
# Bridge/COBOL, the default below) or "modern" (accounts-service/Postgres).
7796
# Flip it with: docker compose up -d --build --force-recreate
7897
# -e ACCOUNTS_SOURCE=modern gateway
7998
# or simpler: ACCOUNTS_SOURCE=modern docker compose up -d gateway
99+
# /api/customers has no such switch — it's a net-new capability with a
100+
# single backend (customers-service), not a legacy/modern migration.
80101
gateway:
81102
build: ./src/CobraBridge.Gateway
82103
image: cobrabridge-gateway
@@ -85,17 +106,18 @@ services:
85106
- AccountsSource=${ACCOUNTS_SOURCE:-legacy}
86107
- Services__Bridge=http://bridge:8080/
87108
- Services__AccountsService=http://accounts-service:8080/
109+
- ReverseProxy__Clusters__customers-svc__Destinations__d1__Address=http://customers-service:8080/
88110
ports:
89111
# Host 8080 is taken by an unrelated local container on this machine;
90112
# the gateway still listens on 8080 inside its own container.
91113
- "8090:8080"
92114
depends_on:
93115
- bridge
94116
- accounts-service
117+
- customers-service
95118

96-
# --- Phase 3c/d: microservices behind the gateway ---
119+
# --- Phase 3d: microservices behind the gateway ---
97120
# transactions-svc: { }
98-
# customers-svc: { }
99121

100122
# --- Phase 4: messaging + dashboard ---
101123
# rabbitmq: { image: rabbitmq:3-management }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# ADR-0006 — Database-per-service on a shared PostgreSQL server
2+
3+
**Status:** Accepted
4+
**Date:** 2026-06-19
5+
6+
## Context
7+
8+
With AccountsService (Phase 3b) and CustomersService (Phase 3c), CobraBridge
9+
now has two independent microservices that both need PostgreSQL. They have
10+
no business sharing data directly — AccountsService never needs a customer's
11+
KYC status, CustomersService never needs an account balance. The only
12+
sanctioned integration point is the gateway/HTTP boundary.
13+
14+
## Decision
15+
16+
Each service owns **its own database**`cobrabridge_accounts`,
17+
`cobrabridge_customers` — created on **one shared PostgreSQL server**
18+
(one `postgres` container in docker-compose, not one container per service).
19+
A service only ever holds a connection string to its own database; nothing
20+
queries across databases.
21+
22+
This is the standard "database-per-service" microservices pattern, scoped
23+
down for a local/demo footprint: it gets the structural isolation (each
24+
service's schema can evolve independently, no hidden coupling through
25+
shared tables) without paying for N separate Postgres processes on a
26+
developer laptop.
27+
28+
## Consequences
29+
30+
- **Positive:** each service's EF Core migrations are independent — adding
31+
a column to `customers` can never accidentally affect `accounts`.
32+
- **Positive:** matches how this would actually scale: moving a service's
33+
database to its own server later is a connection-string change, not a
34+
schema untangling exercise.
35+
- **Positive:** makes the "no cross-service queries" rule structurally true
36+
rather than just a convention someone could quietly violate with a join.
37+
- **Neutral:** still one Postgres *process* to operate locally — full
38+
process-per-service isolation (separate containers, separate failure
39+
domains) is a later, real-infra concern, not a local-dev one.
40+
- **Negative:** the one-time database creation now needs an init step
41+
(`postgres/init/*.sql`, run by the official Postgres image on first boot)
42+
instead of relying on `POSTGRES_DB` alone — one extra moving part to keep
43+
in sync when a future service needs its own database.

docs/architecture.md

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Strangler Fig story into something a recruiter can *see* in ten seconds.
6767
| 0 | Foundations: repo, docs, ADRs, docker-compose skeleton, CI | **done** |
6868
| 1 | Legacy core: GnuCOBOL batch + fixed-width data (containerized) | **done** |
6969
| 2 | The bridge (ACL): C# service wrapping COBOL, first modern endpoint | **done** |
70-
| 3 | Microservices behind the YARP gateway | **in progress (3a: gateway, 3b: accounts + strangler switch)** |
70+
| 3 | Microservices behind the YARP gateway | **in progress (3a: gateway, 3b: accounts + strangler switch, 3c: customers)** |
7171
| 4 | Event-driven + real-time React/SignalR dashboard | planned |
7272
| 5 | CI/CD hardening, observability, tests, polish | planned |
7373

@@ -80,8 +80,46 @@ Phase 3b delivered the modern side of the swap and the Strangler switch
8080
itself — see below. The shared `Account` model, `FixedWidthAccountParser`,
8181
and the legacy-file path resolver moved out of the Bridge into
8282
`CobraBridge.Domain` so AccountsService could reuse them instead of
83-
duplicating them. Phases 3c–d (transactions, customers microservices) are
84-
not built yet.
83+
duplicating them.
84+
85+
Phase 3c delivered CustomersService — see "Migration vs. net-new" below.
86+
Phase 3d (transactions microservice) is not built yet.
87+
88+
## Migration vs. net-new
89+
90+
Not every capability behind the gateway is a Strangler migration of
91+
something COBOL already did. CobraBridge deliberately has both kinds, side
92+
by side, because that's what real modernization looks like:
93+
94+
```
95+
Migrated (Strangler): /api/accounts
96+
COBOL already does this -> Bridge parses it live, AccountsService holds a
97+
migrated copy in Postgres, AccountsSource picks which one answers. The
98+
point is the *swap* being invisible to the client.
99+
100+
Net-new (no legacy equivalent): /api/customers
101+
COBOL never had this -> CustomersService is the only and original source.
102+
There is nothing to migrate, nothing to strangle, no AccountsSource-style
103+
switch — just one backend behind the gateway, like any other service
104+
would be in a system built from scratch.
105+
```
106+
107+
Conflating the two would misrepresent both: claiming `/api/customers` was
108+
"migrated" would invent a legacy capability that never existed, and treating
109+
`/api/accounts` as plain net-new work would hide the actual hard part — the
110+
anti-corruption layer and the live cutover. The gateway config says so
111+
explicitly (see `customers-svc` cluster comments in
112+
`src/CobraBridge.Gateway/appsettings.json`).
113+
114+
## Database-per-service (Phase 3b/3c)
115+
116+
AccountsService and CustomersService each own their own PostgreSQL
117+
database — `cobrabridge_accounts` and `cobrabridge_customers` — on one
118+
shared Postgres server (see [ADR-0006](adr/0006-database-per-service.md)).
119+
Neither service ever queries the other's database; the gateway/HTTP
120+
boundary is the only sanctioned integration point. `postgres/init/*.sql`
121+
creates the second database the first time the data volume initializes
122+
(`POSTGRES_DB` only creates one).
85123

86124
## The Strangler switch (Phase 3b)
87125

@@ -122,18 +160,21 @@ for the full walkthrough.
122160
```
123161
cobrabridge/
124162
├── README.md
125-
├── docker-compose.yml # orchestrates the whole system locally
163+
├── docker-compose.yml # orchestrates the whole system locally
164+
├── postgres/init/ # one-time DB-per-service creation scripts
126165
├── docs/
127-
│ ├── architecture.md # this file
128-
│ └── adr/ # architecture decision records
129-
├── legacy-core/ # the COBOL "mainframe" (Phase 1, runs today)
130-
├── src/ # .NET solution (gateway, services, bridge)
131-
│ ├── CobraBridge.Domain/ # shared Account model, legacy parser, path resolver
132-
│ ├── CobraBridge.Domain.Tests/ # parser + path resolver tests
133-
│ ├── CobraBridge.Bridge/ # anti-corruption layer (Phase 2, done)
134-
│ ├── CobraBridge.AccountsService/ # modern accounts API (Phase 3b, Postgres-backed)
135-
│ ├── CobraBridge.AccountsService.Tests/ # mapper, seeder, endpoint, legacy/modern equivalence tests
136-
│ ├── CobraBridge.Gateway/ # YARP API gateway + Strangler switch (Phase 3a/3b)
137-
│ └── CobraBridge.Gateway.Tests/ # health, routing, and switch tests for the gateway
166+
│ ├── architecture.md # this file
167+
│ └── adr/ # architecture decision records
168+
├── legacy-core/ # the COBOL "mainframe" (Phase 1, runs today)
169+
├── src/ # .NET solution (gateway, services, bridge)
170+
│ ├── CobraBridge.Domain/ # shared Account model, legacy parser, path resolver
171+
│ ├── CobraBridge.Domain.Tests/ # parser + path resolver tests
172+
│ ├── CobraBridge.Bridge/ # anti-corruption layer (Phase 2, done)
173+
│ ├── CobraBridge.AccountsService/ # modern accounts API (Phase 3b, migrated, Postgres-backed)
174+
│ ├── CobraBridge.AccountsService.Tests/ # mapper, seeder, endpoint, legacy/modern equivalence tests
175+
│ ├── CobraBridge.CustomersService/ # customer/KYC API (Phase 3c, net-new, Postgres-backed)
176+
│ ├── CobraBridge.CustomersService.Tests/ # mapper, seeder, endpoint, KYC-filter tests
177+
│ ├── CobraBridge.Gateway/ # YARP API gateway + Strangler switch (Phase 3a/3b/3c)
178+
│ └── CobraBridge.Gateway.Tests/ # health, routing, and switch tests for the gateway
138179
└── .github/workflows/ci.yml # build + test pipeline
139180
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Database-per-service (see docs/architecture.md and ADR-0006): each
2+
-- service owns its own database on this one shared Postgres server.
3+
-- POSTGRES_DB (docker-compose) creates "cobrabridge_accounts" for
4+
-- AccountsService automatically; this script creates the second database,
5+
-- for CustomersService. Runs once, only when the postgres-data volume is
6+
-- first initialized.
7+
CREATE DATABASE cobrabridge_customers;

src/CobraBridge.AccountsService/Program.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
});
1616

1717
// Dev-only default credentials — never used outside this local/demo stack.
18-
// docker-compose and any real deployment override this via ConnectionStrings__Accounts.
18+
// Database-per-service: "cobrabridge_accounts" is this service's own
19+
// database (see docs/architecture.md). docker-compose and any real
20+
// deployment override this via ConnectionStrings__Accounts.
1921
var connectionString = builder.Configuration.GetConnectionString("Accounts")
20-
?? "Host=localhost;Port=5432;Database=cobrabridge;Username=cobrabridge;Password=cobrabridge_dev_only_change_me";
22+
?? "Host=localhost;Port=5432;Database=cobrabridge_accounts;Username=cobrabridge;Password=cobrabridge_dev_only_change_me";
2123

2224
builder.Services.AddDbContext<AccountsDbContext>(options => options.UseNpgsql(connectionString));
2325

src/CobraBridge.AccountsService/appsettings.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
"AllowedHosts": "*",
99

1010
// Dev-only default credentials for the local/demo Postgres instance.
11-
// Never used outside this project's compose stack; override in real
12-
// deployments via the ConnectionStrings__Accounts env var.
11+
// Database-per-service: "cobrabridge_accounts" is this service's own
12+
// database, separate from CustomersService's "cobrabridge_customers", on
13+
// the same Postgres server. Never used outside this project's compose
14+
// stack; override in real deployments via the ConnectionStrings__Accounts
15+
// env var.
1316
"ConnectionStrings": {
14-
"Accounts": "Host=localhost;Port=5432;Database=cobrabridge;Username=cobrabridge;Password=cobrabridge_dev_only_change_me"
17+
"Accounts": "Host=localhost;Port=5432;Database=cobrabridge_accounts;Username=cobrabridge;Password=cobrabridge_dev_only_change_me"
1518
}
1619
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
14+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
15+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
16+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
17+
<PackageReference Include="xunit" Version="2.5.3" />
18+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<Using Include="Xunit" />
23+
</ItemGroup>
24+
25+
<ItemGroup>
26+
<ProjectReference Include="..\CobraBridge.CustomersService\CobraBridge.CustomersService.csproj" />
27+
</ItemGroup>
28+
29+
</Project>

0 commit comments

Comments
 (0)