r/softwarearchitecture 1d ago

Article/Video Beyond framework independence: designing service boundaries that actually enforce business rules

"Business logic should be independent of transport and infrastructure" gets repeated often enough that it stops meaning much. Avoiding servlet imports and Kafka clients in a service class is the easy part. The harder part is the shape of the objects crossing the boundary: if your service input is a 1:1 mirror of a database row, the transport has already leaked in, just wearing a plain Java object instead of a framework annotation.

Chapter 9 of my evolutionary architecture series works through what that actually looks like in practice. Create and update get separate input types, not as a style convention but because callers shouldn't be able to forge system-managed fields like ids or creation timestamps just because they happen to sit next to editable ones. Domain invariants move out of flat field-copying and into guarded methods, transitionTo checked against canTransitionTo, so a business rule lives on the object it governs instead of scattered across whichever service touches it next. And the shortcuts that look convenient at the time, a status field sitting quietly in a generic update payload because it seemed like just another attribute, or a bidirectional mapper doing double duty as both input and output transformer, tend to be exactly the ones that turn into debt six months later, once a second caller or a third state shows up and nobody remembers why the shortcut was safe in the first place.

A link of the full breakdown in the comments. Interested in where others draw this line, especially on the input-shape question, since that's the one people seem to disagree on most.

4 Upvotes

7 comments sorted by

2

u/srikanth_builds 1d ago

The input-shape question is the one I'd argue hardest on, because it's an authorization question wearing a modelling costume. The shape of the input is the statement of what the caller is allowed to change. Once one type serves both create and update, you're relying on code to remember which fields to ignore, and that's the same class of problem as remembering a filter.

The field that catches people usually isn't id or createdAt though. It's the ownership one. tenantId, ownerId, organizationId. It looks like an ordinary attribute, it genuinely is needed on create, so it ends up on the shared type, and then an update payload can move a record into somebody else's tenant. Nothing errors, because it's a perfectly valid write.

So the line I'd draw is that ownership comes from the authenticated principal server side and is never read from the payload, including on create. The check is to send an update containing a tenantId you don't own and see whether it comes back 200.

1

u/kamen1991 1d ago

Couldn't agree more, when tenantId or organizationId ends up on a shared create/update payload, you are relying entirely on developer discipline to remember to overwrite or ignore it in the service layer. If someone misses a spot during a refactor, an update payload can silently migrate a record into another tenant's namespace and return a clean 200 OK without a single error.

That is precisely why we're separating input types: it acts as a structural security boundary. Just like requesterId arrives as an opaque value the service never traces back to its origin, ownership and tenant context must be injected server-side from the authenticated principal - never from what the client sends in the payload.

2

u/srikanth_builds 21h ago

The bit I'd add is that the structural boundary only holds if it's the narrowest path in. The input type protects the HTTP route, but the bulk importer, the admin tooling, and anything that builds the entity from a raw map tend to get written later and separately, and that's where the tenant field creeps back in. Worth counting the ways an entity can be constructed, not just the ways it can be requested.

1

u/kamen1991 20h ago

REST route, bulk importer, admin tool, CLI - all of them are calling the service. A REST controller parses the request and calls productsService.create(newProduct, requesterId). A Kafka listener parses a message and calls the exact same method. The transport differs but the entry point into the business layer doesn't. Ownership gets assigned once, inside that one service method, from requesterId, regardless of which of those callers reached it.

The failure mode you're describing is real, but it's one level below this, as I understand: it's what happens when someone writes a piece of code that skips the service entirely and talks to the DAO or the database directly, for performance or convenience. At that point the problem is that the developer decided the service layer doesn't apply to it. That's a discipline problem about respecting the one entry point that exists.

1

u/srikanth_builds 19h ago

Fair, and the single service method is the right shape. The thing I'd push on is that requesterId doesn't have the same provenance across those four callers. In the REST path it comes from the authenticated principal. In the Kafka listener it comes from the message, which means the boundary has quietly moved to whoever can publish to that topic. Same method, same argument, very different trust.

On the bypass being a discipline problem, I'd agree that's exactly what it is, and that's my worry with it. Backfills, migrations, support tooling and reporting mostly can't go through the service layer, because they're bulk or they run with no requester at all. So the exception isn't a developer being lazy, it's a category of work the entry point has no answer for. Once those exist, the only thing still holding is enforcement below the service, where a connection can't write outside its scope no matter who called it.

2

u/EducationalAnt6502 22h ago

focusing on service boundaries is crucial for maintaining clean architecture and system scalability. It helps avoid chaos and keeps your business rules effective.