Skip to main content
← Back to blog
8 min readSystems & Infra

Converging my portfolio ecosystem onto a single write path

Once my portfolio site grew past a certain size, I realized that writing data was happening in too many places — the site's own API wrote to Mongo, the content admin connected straight to Mongo, and I'd even shoved posts in by hand with a script. Each entry point "knew" a little bit of the rules: articles need a unique slug, the Chinese title and body are required, the category must be one of five… The rules were duplicated, and duplication drifts.

This is the story of converging every write in the ecosystem onto one validated path.

The problem: many entry points, many copies of the rules

One posts collection, three places that wrote to it. If any one of them forgot a rule, a bad document could slip in — an article with no slug, say, whose detail page then 404s. Worse, the rules lived everywhere, so changing one meant editing several files. "Consensus by discipline" doesn't survive scale.

What I wanted: no matter which client writes, there is exactly one gate that validates, and the rules are written once.

The decision: a central API service layer

So I stood up a third backend repo, tkflyc-webservice — a central API whose job is to wire services together — built with NestJS and textbook DDD / Clean Architecture. My guiding principle is "the domain name is the role; high cohesion, low coupling," so it gets its own domain and its own responsibility: every write and read flows through it.

The layering is plain, dependencies always pointing inward:

domain/          Post aggregate + invariants (the single source of the rules), Repository port
application/     CQRS commands / queries + handlers, ports for outside dependencies
infrastructure/  MongoDB implementation, the revalidate call (fills the ports)
interfaces/http/ auth, exception mapping, the two public interfaces

The rules are locked inside the Post aggregate in the domain layer. Its checkInvariants is the system's one and only definition of "what a valid post is":

article / devlog need a Chinese title and body; article needs a unique slug;
publishedAt must be a valid date. Break one → DomainError → HTTP 400.

Infrastructure hooks in via ports and adapters: the domain only knows an abstract PostRepository; the real Mongo implementation is injected from infrastructure. Swap the database, or write a test, and the domain doesn't move.

Packet dispatch: reading opcodes like a game server

I deliberately made request handling an explicit command dispatch, the way a game server does it. A request is a packet:

POST /gateway  { op, data }
        │
        ▼
   OP_TABLE[op]   ← table lookup (op = the packet head)
        │
        ▼
  build a Command / Query → onto the CQRS bus → the matching handler processes data

OP_TABLE is that "opcode → handler" table, one row per operation:

'blog.create': {
  kind: 'command',
  build: (d) => new WriteBlogCommand(d),
  present: (r) => toPostResponse(r),
},

Adding a new service operation is one row in the table plus its command / handler — no new route. The payoff of table-driven dispatch: a single entry point, centralized behavior, and a fixed cost to extend. It's a mental model I'm at home in — it's the same shape as a game server reading a packet, looking up the opcode, and handing it to a handler.

One path, two interfaces

Over the same CQRS bus I expose two interfaces: the packet gateway above, and a resource-style REST /posts. Both end up on the same bus and the same domain validation — in other words, there can be many interfaces, but only one write path.

The hybrid path: knowing when not to unify

Unifying writes sounds great, but I kept one deliberate exception: auto-ingested tech news (from RSS) still writes directly to Mongo. The webservice's Post aggregate only models article/devlog fields — it has no place for a news item's source link, original headline, or my commentary. Forcing news through the gateway would silently drop those fields.

That's a judgment I value more than blind unification: convergence is for eliminating duplicated rules, not for erasing every difference. News has its own shape; let it keep its own path.

Wiring clients: unify the entry, not down to "one" client

With the gateway in place, I rewired the content admin's article/devlog writes to go through it; whoever adds a post no longer needs to know what Mongo looks like, and a malformed post comes back as a plain, precise error.

Along the way I wrote a posting CLI — then scrapped it. Amusingly, my gut reaction was "isn't that just opening a backdoor?" But it hit the same gateway, with the same token and the same validation as every other client. It wasn't another path, just another client. There's an easy-to-blur distinction here:

"A single write path" is a write-layer property (everything → gateway → validated once), and that is not the same as collapsing to one client.

The CLI never violated the single path. I removed it for a different reason: one fewer entry point to maintain, and no long-lived tokens scattered as files across machines. What's left is two entry points — the interactive API docs page (Swagger) for humans, and the content admin — both over the same gateway. Less is more.

All self-hosted on one laptop

The whole thing — front end, central API, content admin, database — self-hosts on a single laptop, held up by ngrok custom domains plus the Windows Task Scheduler (start on boot, restart on crash). No cloud, no k8s; one warm laptop is my production.

After the convergence

Now, wherever a post is written from, it passes the same domain validation, lands in the same place, and pings the front end to refresh. One copy of the rules, a countable number of entry points, every write traceable.

This article itself was posted through that new gateway.