A business layer is only as independent as the shape of the objects that cross its boundary. It doesn't matter how carefully a service class avoids importing javax.servlet or a Kafka client if the object it accepts as input is a one-to-one mirror of the database row. At that point the transport has already leaked in, just wearing a plain Java object instead of a @RequestBody annotation. The rule this chapter works from is narrow enough to apply consistently: a service method's input should reflect what a caller is allowed to do, not what the entity happens to look like. Creating a product and updating one are different operations with different rules about what's mutable, who sets what, and what's off-limits entirely - so they get different input types, even when large parts of them overlap. The same goes for who's asking: a requesterId arrives as a bare UUID, and the service has no idea about whether it came from a JWT claim, a session, or a Kafka message header. That indifference is the actual independence being aimed for, not the absence of framework imports.
What makes this rule worth a full chapter, rather than a paragraph, is the case where following it isn't obvious, where a field looks like it belongs on one side of the boundary until you actually reason through what the operation is supposed to guarantee.
What "independent" is supposed to mean
"Business logic should be independent of transport and infrastructure" gets repeated often enough that it stops meaning anything. In practice, for this service layer, it means three concrete things:
The service methods don't know whether the caller is a REST controller, a Kafka listener, or a CLI tool. They take a requesterId as a UUID and a domain-shaped input, and that's the entire contract. Nothing about how that UUID was extracted - JWT claim, session or a message header.
The service methods don't know what database sits behind the DAO. AbstractCrudService depends on CrudDao<UUID, Domain>, an interface. Whether that's MySQL, an in-memory test double, Apache Coherence or something else entirely is invisible from here.
And the service methods don't accept whatever shape of data happens to be convenient for the caller. They accept exactly the shape that matches what the operation is allowed to do. That third point is where status transitions turn out to be a harder case than they first look, so it's worth slowing down on.
Different intent, different object
NewCategory carries just a name - a category is created without a provided status at all, it's INACTIVE by default until someone deliberately changes that. UpdateCategory carries a name too, because renaming a category turned out to be a legitimate, unremarkable operation with no special rule attached to it. What it doesn't carry is status, and that absence is the interesting part:
public class UpdateCategory {
private String name;
// no status field
}
That's not the same reasoning as leaving a field off because it's immutable, name isn't immutable here, it's just not entangled with anything else. Status is left off for the opposite reason: changing it is entangled with a rule, the same kind Product enforces through transitionTo. A category can move from INACTIVE to ACTIVE or straight to ARCHIVED, but once ACTIVE it can only be archived, and ARCHIVED is terminal - no path back. None of that belongs in a generic update method next to a name change, so it doesn't live there. It lives in its own operation:
public void changeStatus(UUID id, CategoryStatus newStatus, UUID requesterId) {
final var category = loadOrThrowNotFound(() -> getDao().loadById(id));
authorize(category, requesterId);
category.transitionTo(newStatus);
category.setUpdatedById(requesterId);
getDao().update(category);
}
This category originally shipped with a plain active boolean, and for a while that was a perfectly reasonable representation, there were exactly two states, and a flag models two states fine. The same reasoning that shaped ProductStatus into a guarded enum applied here too, once a third state showed up: a boolean has no way to express "archived" without either overloading active = false to mean two different things, or bolting on a second flag and hoping the combination of both flags never goes somewhere nonsensical. The category status shows that lesson getting applied a second time, ahead of the problem this time rather than after it, a CategoryStatus enum with the same canTransitionTo shape as ProductStatus, so a name change and a status change stay two operations that can never be confused for one.
NewProduct and UpdateProduct follow the same instinct for most fields, no caller ever gets to set id, createdAt, or createdById from outside, because those fields don't exist on either input object. They're populated by the service (preProcessNewEntity) or never populated by input at all. A create payload and the domain object it produces are not the same shape, on purpose, and that gap is the whole point: it's not possible to forge a creation timestamp or claim someone else's authorship, because there's no field to put it in. The same discipline shows up in how UpdateProductTransformer handles the embedded specification, and it's a smaller but no less deliberate decision:
if (product.getSpecification() == null) {
product.setSpecification(new ProductSpecification(dto.getDimensions(), dto.getWeight()));
} else {
product.getSpecification().setDimensions(dto.getDimensions());
product.getSpecification().setWeight(dto.getWeight());
}
An update caller sends flat dimensions and weight fields, not a ProductSpecification object. The transformer decides whether that means constructing a new embedded object or mutating an existing one, and the caller never has to know or care which branch ran. That's the input shape matching the operation, not the internal object graph, a product created without dimensions can still be updated to add them later, without the caller needing to understand that ProductSpecification exists as a separate type at all.
Status is where that question gets harder to answer by instinct, and it's worth reasoning through it explicitly. At first glance, status looks like it belongs on UpdateProduct next to name, SKU, and price, it's a field on the entity, the caller wants to change it, so it goes in the update payload with the rest. That reasoning holds right up until you ask what "changing status" is actually supposed to mean, and the domain object itself answers that question before the DTO gets a chance to:
public void transitionTo(ProductStatus nextStatus) {
if (!this.status.canTransitionTo(nextStatus)) {
throw new IllegalStateException(
"Business rule violated: Cannot transition from " + this.status + " to " + nextStatus);
}
this.status = nextStatus;
}
Product doesn't expose a setStatus. It exposes a guarded transition. There's no legal way to skip from DRAFT straight to ARCHIVED, or to move backward from PUBLISHED to DRAFT, without the domain itself refusing. That's the correct place for that rule to live - not in a controller, not in a transformer, not scattered across whichever service happens to touch the domain next. Which means a plain field copy in the transformer - product.setStatus(dto.getStatus()) was never going to be the right move, regardless of whether anyone remembered to write it. It would have bypassed canTransitionTo entirely, applying whatever status the caller sent without checking if the move was legal. Once that's clear, the field doesn't belong on UpdateProduct at all, not because leaving it there caused a defect, but because a generic update method has no business deciding whether a status transition is allowed. That's a different kind of operation, with its own rule, and it earns its own method - the same one Category already uses:
public void changeStatus(UUID id, ProductStatus newStatus, UUID requesterId) {
final var product = loadOrThrowNotFound(() -> getDao().loadById(id));
authorize(product, requesterId);
product.transitionTo(newStatus);
product.setUpdatedById(requesterId);
getDao().update(product);
}
It's worth noticing that this is the exact same shape as Category.changeStatus - same load, same authorize call, same transition, same save. Neither entity needed a bespoke publish() or activate() method with its own hand-rolled orchestration. The variation between products and categories lives entirely inside canTransitionTo, where it belongs, not in how the operation is wired up around it.
Now there are three independent layers standing between a caller and an invalid state: the DTO doesn't carry the field at all, the service exposes the change as a named, intentional action, and the domain object enforces the transition rule regardless of who calls it. Any one of those layers failing doesn't compromise the other two. That's worth more than a single well-placed validation, because it's the kind of protection that survives someone else touching the code six months from now without having read this chapter.
The same status reasoning extends one layer further, past persistence. CategoryStatus - the domain enum with canTransitionTo and CategoryStatusEntity the JPA-mapped enum stored as a string column are two separate types, converted explicitly at the DAO boundary rather than shared directly. The domain module never imports anything from jakarta.persistence, so it has no idea EnumType.STRING exists, or that the column is even a string rather than an integer code. That's the same independence argument as the transport boundary, aimed at the other side of the service: the business rule for what states a category can move through doesn't change if the storage representation does.
The template, and what it deliberately doesn't decide
AbstractCrudService is the same shape for both ProductsServiceImpl and CategoriesServiceImpl, and that repetition is intentional rather than lazy. create, update, loadById, and delete are implemented once, in the base class, calling out to a small set of abstract hooks:
protected abstract Transformer<CreateDomain, Domain> getCreateTransformer();
protected abstract Transformer<UpdateDomain, Domain> getUpdateTransformer();
protected abstract void authorize(Domain domain, UUID requesterId);
Each concrete service answers three questions - how do I build this domain object from a create payload, how do I apply an update payload, and who's allowed to touch this domain object and the orchestration around those answers (transaction boundaries, null checks, the not-found and unauthorized exceptions) is written exactly once.
It's worth addressing the one place this chapter's independence argument looks like it bends: every write method carries @Transactional, a Spring annotation, sitting directly in what's supposed to be framework-agnostic business logic. That's a smaller compromise than it looks. @Transactional is declarative metadata, read reflectively by a proxy Spring builds around the class - the method itself never touches a TransactionManager, never begins or commits anything, never knows the annotation is even being honored. Strip Spring from the classpath and the method still compiles and runs, just without the transactional wrapping. That's a fundamentally different kind of coupling than a JPA entity or an HttpServletRequest parameter would be, where the method literally cannot execute without the framework type on the classpath.
The alternative - moving transaction demarcation out to the controller, or configuring it externally via XML or AspectJ doesn't remove the framework, it just relocates a decision that belongs here anyway: what counts as one atomic unit of work for a given operation is a business question, not a transport one. A controller has no basis for deciding whether update() should be one transaction or two. That's exactly why this rule is enforced at exactly one layer. @Transactional lives on service methods, in this base class or its concrete implementations, and nowhere else. Not on controllers, not inside DAO implementations. A transaction boundary that can start from either direction is a transaction boundary nobody can reason about.
Notice also what Transformer<CreateDomain, Domain> doesn't do: it only moves data one direction, from the create or update input object into the domain object it produces. There's no copyToInput here for turning that domain object back into a response shape, that's a different interface entirely, used elsewhere in the codebase for DTO serialization. Collapsing the two into one bidirectional transformer would be a natural shortcut, since a lot of the field-copying code would look identical going either direction. It would also mean the same class that decides how a creation payload becomes a product is now also deciding how a product becomes an API response and the moment those two responsibilities share a class, it becomes easy to add a field to one direction and forget the other silently breaks the boundary this chapter is about. Keeping create/update transformers strictly one-way means that possibility doesn't exist structurally, not just by convention.
authorize is worth pausing on, because it takes the fully loaded domain object, not just an id:
protected void authorize(Product domain, UUID requesterId) {
if (!domain.getCreatedById().equals(requesterId)) {
throw new AuthorizationException(UNAUTHORIZED);
}
}
That forces a load before the authorization check can run, which looks like it could be optimized away. It can't, not without losing generality. Ownership by creator works for products, but nothing guarantees the next domain object's access rule is that simple, it could depend on a collaborator list, a tenant boundary, a status field, any combination of the object's own state. The moment authorization can depend on arbitrary domain content, loading the object first stops being a choice and becomes a requirement. The base class doesn't try to special-case this, it just accepts the domain object as a parameter and lets each subclass decide what "authorized" means for that domain.
delete carries a smaller, quieter version of the same idea:
final var domain = dao.loadById(id);
if (domain == null) {
return;
}
Deleting something that doesn't exist isn't an error here - it's a no-op. That's a defensible, idempotent design, and it matches how HTTP itself defines the semantics of DELETE: RFC 7231 specifies DELETE as idempotent, meaning repeated identical requests should leave the system in the same end state as a single one - a resource that's already gone stays gone, without that repetition needing to be an error. It's also easy to violate by accident. When someone adds anything after dao.delete() in this method - an audit entry, a notification, anything - that addition needs to remember this early return exists, or it'll fire for deletes that never happened. Worth a comment at the call site, not because the current code is wrong, but because the next change to it won't have this context unless it's written down.
What this buys, concretely
None of this is abstract payoff. A ProductsServiceImpl constructed with nothing but a ProductDao can be exercised with a mocked DAO and zero Spring context - no database, no HTTP layer, no message broker, because none of those things are reachable from inside the class in the first place. The transformer contract only moves data one direction, so there's no path by which a create or update payload construction accidentally becomes the shape used for API responses later. And status transitions, once reasoned through as their own kind of operation rather than just another field, end up structurally incapable of bypassing the domain object's own rules, not because someone remembered to guard against it, but because the shortcut was never given a path to exist.
The same boundary is what makes future changes cheap instead of invasive. If ProductDao moves from MySQL to something else, or gets a caching layer bolted in front of it, ProductsServiceImpl doesn't change, it was never coupled to the implementation, only the interface. The same will hold when this service eventually needs to notify the rest of the system that a product was created or updated, that's a dependency the service will accept through its constructor, the same way it accepts the DAO now, and the create and update methods above won't need to know whether that notification goes out over Kafka directly or through an outbox table written in the same transaction. That's a later chapter's problem specifically because this chapter's boundary makes it one.
The tests that prove all of this - what a mocked DAO actually looks like, how you assert an unauthorized update throws before it touches persistence, how you write a test that locks in a status transition rule so a future change can't quietly loosen it are their own chapter. This one was about the shape the code has to have before those tests are worth writing.
What's Next?
Chapter 10 walks through testing this exact service layer with the DAO mocked out entirely, no Spring context, no database, no HTTP layer, using ProductsServiceImpl and CategoriesServiceImpl as the running examples. The boundary this chapter drew is what makes those tests possible in the first place - the next one is about actually writing them, including the test that would have caught the status field slipping through if it had shipped that way.
Codebase & Architecture Blueprint
The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 9, use the following link:
-
GitHub Repository (Tag:
chapter-09-business-logic): advanced-spring-multimodule
Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.
◀️ Read Chapter 8: Why almost every ‘Temporary’ Workaround is Permanent
▶️ Read Chapter 10: The Testing of Business Logic Service (Coming soon)
Recommended Resources for Java & Spring Boot Engineers
If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot & Architecture skills, check out these highly-rated resources from the Javarevisited publication (Use promo code friends20 for an exclusive 20% discount automatically applied at checkout):
- Grokking the Java Interview Prepare for core Java, concurrency, JVM internals, and design pattern questions. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the Spring Boot Interview Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the SQL Interview Deep-dive into query optimization, indexing, joins, and complex SQL window functions. Get Full Book (Paid) | Download Free Sample Copy
- The Complete Java + Spring + SQL Interview Bundle Get all three interview guides in a single heavily discounted package. Get the Ultimate Interview Bundle
- Spring Professional Certification Practice Questions (250+ Questions) Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification. Get the Spring Professional Questions | Download Free Sample Copy
Top comments (0)