CQRS Without the Astronaut Architecture
You don't need event sourcing or a second database. Just split reads from writes, and finally fix the 20-method service nobody wants to open.
Your OrderService has 20 methods. Half of them place orders, cancel them, apply discounts — real business logic. The other half just fetch data and hand it to a screen.
These two halves have nothing in common except the file they live in. They change for different reasons. They run at different speeds. They need different things. And they’re tangled together.
CQRS is the idea that you should stop pretending they’re the same thing.
Not the heavy, event-sourced, two-database version everyone writes about. The small one. The one you can add to a normal Node app this week.
That’s the one I actually use, and it’s the one we’ll talk about here.
Share this post & I’ll send you some rewards for the referrals.
The agent harness wasn’t supposed to be the black box (Partner)
Agent loop is the most important piece of infrastructure in your workflow right now and for most developers, it’s the one piece they can’t open up. Agent builders have to jump through all the hoops themselves, crafting the infrastructure and tools, testing the harness, while fighting to maintain what they’ve built.
Meet Cline SDK: agent harness behind Cline 2.0, fully open-sourced. The same runtime that powers Cline across VS Code, JetBrains, and the CLI is now an npm install away: npm i @cline/sdk. Inspect it, fork it, extend it, ship on it.
Best-in-class harness: 74.2% on Terminal-Bench 2.0 with Claude Opus 4.7 ahead of Claude Code (69.4%) and strongest numbers published on open-weight models.
Open model & provider choice: Anthropic, OpenAI, Google, Bedrock, Mistral, or any OpenAI-compatible endpoint.
Real plugin system: Register tools, hooks, commands, providers, message builders. Prototype as a local file, harden into a package. Extend it freely for any of your agent use cases.
Scheduled + event-driven agents: Cron and event specs for PR reviews, dependency checks, coverage audits, changelogs no separate orchestration layer.
Stop building around your agent. Start building on it.
Install Cline SDK today: npm i @cline/sdk. Or try the rebuilt harness directly: npm i -g @cline
(Thanks to Cline for partnering on this post.)
One class, two jobs
You've seen this class. It does everything:
class OrderService {
// Writes — validate, enforce rules, change state, emit events
placeOrder(dto: PlaceOrderDto): Promise<Order>
cancelOrder(id: string, reason: string): Promise<void>
applyDiscount(id: string, code: string): Promise<Order>
// Reads — fetch, join, map to a shape the UI wants
getOrderById(id: string): Promise<OrderDto>
getOrderSummary(id: string): Promise<OrderSummaryDto>
searchOrders(filters: SearchFilters): Promise<OrderDto[]>
}The writes are heavy. They check input, enforce business rules, update state, and fire off events. They get more complex every time the business changes its mind.
The reads are light. They grab some rows, join a few tables, and map the result to whatever the screen needs. They change when the UI changes — not when a pricing rule does.
So here’s the thing.
A pricing rule touches the writes. A redesign touches the reads.
Two completely different forces, pulling on the same class. That’s two reasons to change bolted together, and one is exactly one too many.
What CQRS actually is (and isn’t)
CQRS stands for Command Query Responsibility Segregation. The name is scarier than the idea.
First, what it does not require:
❌ Two separate databases
❌ Event sourcing
❌ Message queues between the read and write sides
❌ Eventual consistency
Those are things you can add later, at the far end of the road. None of them are the price of entry. People see “CQRS” and brace for all four, then never start. Don’t.
Here’s the whole idea:
you write through one model and read through a different one.
If you know CQS (Command Query Separation), the old rule that a method either changes state or returns it, never both.
CQRS is that same idea, promoted from single methods up to whole models. Writes go through a rich path with your domain logic. Reads go through a lean path that returns exactly the shape the caller wants.
Same database. Same deployment. Just two paths instead of one.
The write side stays rich
A command is just data, the intent to do something. The handler is where the logic lives.
// The command: plain data, no behavior.
interface PlaceOrderCommand {
userId: string;
items: { productId: string; quantity: number }[];
shippingAddress: Address;
}
// The handler: this is where the rules live.
class PlaceOrderHandler {
async execute(cmd: PlaceOrderCommand): Promise<string> {
// validate the input
// check inventory for every item
// build the Order domain object
// save it
// publish an OrderPlaced event
return order.id;
}
}Note: I left the body as comments on purpose. The steps matter more than the lines.
The write side validates, enforces invariants, builds a real domain object, and tells the rest of the system what happened. It usually returns almost nothing: an ID, or void.
You called it to change something, not to read it back.
The read side goes lean
The read side skips all of that. No domain objects. No invariants. Just go get the data and shape it.
// No domain layer. Straight to the flat shape the screen needs.
class GetOrderSummaryHandler {
async execute(q: GetOrderSummaryQuery): Promise<OrderSummaryDto | null> {
const { rows } = await this.db.query<OrderSummaryDto>(
`SELECT o.id AS "orderId",
o.status,
COUNT(oi.id)::int AS "itemCount",
o.total_amount AS "totalAmount"
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = $1
GROUP BY o.id`,
[q.orderId],
);
return rows[0] ?? null;
}
}Note: One quick gotcha, because it bites everyone: with node-postgres, a COUNT(...) comes back as a bigint and a numeric column comes back as a string. So pg hands you "3", not 3, and your itemCount: number is quietly lying. Cast it in SQL (::int, like above) or parse it on the way out. The TypeScript type won’t save you here; it believes whatever you tell it.
What do you get for this? Mostly clarity.
The read path isn’t building a full Order aggregate just to render a summary card.
And because it’s its own path now, you can tune it on its own later: a view, a cache, a read replica, without touching a single business rule.
That’s the real payoff at this level. The big performance wins come further down the road; we’ll get there.
Do you even need a bus?
Most CQRS write-ups hand you a CommandBus and a QueryBus next. A central place to dispatch everything, with a nice spot to hang logging, auth, and transactions. Useful. Also completely optional. You can just call the handler.
But if you do build one in TypeScript, build it right. The version you’ll copy off the internet looks like this:
bus.dispatch<string>('PlaceOrder', command); // looks typed, isn'tThat <string> is you telling the compiler the answer. The bus keys on a string and stores handlers as unknown, so nothing connects 'PlaceOrder' to its command type or its result type. You’ve rebuilt a plain function call and thrown the type checking in the bin.
If you want a bus, anchor it to a map type so the names actually mean something:
interface CommandMap {
PlaceOrder: { cmd: PlaceOrderCommand; result: string };
CancelOrder: { cmd: CancelOrderCommand; result: void };
}
// dispatch<K extends keyof CommandMap>(name: K, cmd: CommandMap[K]['cmd'])
// → Promise<CommandMap[K]['result']>Now a typo in the name is a compile error, and the command and result types are checked for you. If that registry feels like more machinery than you'll get value from, skip the bus. Wire the handlers up directly.
The split between reads and writes is the point. The bus is just plumbing.
When to skip CQRS
Be honest with yourself. This adds a layer, and a layer you don’t need is just friction.
⛔ Skip it when:
Reads and writes are about equally simple.
It’s basically CRUD with no real business rules.
You’ve got a handful of operations, and they fit in your head.
It’s a prototype.
✅ Reach for it when:
The read shape and the write shape have drifted apart.
Writes are full of rules, but reads are plain lookups.
You want to optimize reads on their own: caching, denormalized tables, a replica.
One service class has grown past ~15 methods, and you dread opening it.
How far you can take it
The nice part: this scales when you need it to, and not a day sooner. There’s a ladder.
Same DB, separate code paths. Start here. This is the whole post.
Read replicas. Send queries to a replica, commands to the primary.
Separate read store. Build read-optimized views from your events.
Event sourcing. Store events instead of state, project them into read models.
Most teams never climb past step 1. The code-level split alone is what makes the thing easier to test, change, and reason about. Steps 2 through 4 are infrastructure you buy later, if the numbers ask for it.
So don’t architect for a scale you don’t have.
Split the paths, take the design win, and let future-you handle future-you’s traffic.
📌 TL;DR
CQRS is code organization first, infrastructure second. Separate classes for reads and writes — same database.
It does not mean two databases, event sourcing, queues, or eventual consistency. Those come later, if ever.
Writes stay rich: validate, enforce rules, build domain objects, emit events. Return almost nothing.
Reads go lean: straight to a flat DTO, no domain layer, no side effects. (Watch the
pgstring/bigint gotcha.)The bus is optional. If you build one, type it with a registry map — a string-keyed bus is fake type safety. Otherwise call handlers directly.
Skip it for simple CRUD and prototypes. Reach for it when read and write models drift apart.
Start at step 1 and stay there until metrics force you up the ladder.
The goal here isn’t an enterprise architecture astronaut’s dream. It’s just to stop pretending that reading an order summary and placing a new order are the same kind of work.
They’re not. Your code should say so.
See you next time, Petar! 🙏
Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏





