DEV Community

Cover image for The Domain Type That Doesn't Fit: Many-to-Many Links and Bulk Operations (Chapter 12)
Kamen
Kamen

Posted on Originally published at kamenivanov.substack.com

The Domain Type That Doesn't Fit: Many-to-Many Links and Bulk Operations (Chapter 12)

CategoryAssignment records that a product belongs to a category, who put it there, and when. Three ids and a timestamp. It is the simplest type in this project, and it is the first one that does not fit anything the previous eleven chapters built.

Not in one place. In three, at three different layers, for the same reason.

It cannot extend AbstractCrudService, because that class implements CrudService<NewDomain, UpdateDomain, Domain> and there is no update operation to put in the method. An assignment is created or deleted, a changed assignment is a different assignment. It cannot extend CrudDaoImpl either, which requires a CrudRepository and implements a CrudDao carrying update and delete(Domain). And the inherited persistence tests in AbstractCrudTestCase are bound to CrudDao, so not one of them applies.

Three refusals, one cause. The template was built for types with a lifecycle, and this type has no lifecycle - it has an existence.

That is the principle I keep returning to in this series. Shared behaviour belongs in a base class when the shared ancestry is real, not when two implementations happen to look alike today. Products and categories share a lifecycle, so they share a base class. An assignment shares a creation timestamp with them and nothing else.

One thing about the domain needs stating before the code, because it decides most of what follows. Categories are platform taxonomy. An administrator creates them, archives them, and nobody else edits them. In a real deployment they would arrive from a Flyway migration or an admin screen and the products belong to the seller who created them. So an assignment links two objects with different owners, which is the clearest case there is for giving it a type of its own rather than a column on one side. A category cannot carry a list of products belonging to other people, and a product cannot own its place in a shared taxonomy.

Before any of that can be built, though, the type itself needs fixing. The version published back in Chapter 2 had a single constructor taking an id, which left no way to build a domain object that has not been persisted yet. The fields were not final, and by the time I reopened the file for this chapter it had grown a setter for each of the three ids - a type whose entire identity is the pair it links, with a public way to change that pair after the fact. Chapter 2 also called it a "Relationship Entity", and in this series that word now belongs to the JPA class in dao-impl, so I will say domain type from here on.


The type, corrected

public class CategoryAssignment extends AbstractCreatable<UUID> {

    private final UUID categoryId;
    private final UUID productId;
    private final UUID assignedById;

    public CategoryAssignment(
        UUID categoryId,
        UUID productId,
        UUID assignedById
    ) {
        super();
        this.categoryId = Objects.requireNonNull(categoryId, "categoryId is mandatory");
        this.productId = Objects.requireNonNull(productId, "productId is mandatory");
        this.assignedById = Objects.requireNonNull(assignedById, "assignedById is mandatory");
    }

    public CategoryAssignment(
        UUID id,
        Instant createdAt,
        UUID categoryId,
        UUID productId,
        UUID assignedById
    ) {
        super(id, createdAt);
        // same three null checks
    }
    // Getters
}
Enter fullscreen mode Exit fullscreen mode

The first constructor builds a domain object that has not been persisted. AbstractCreatable leaves its id null and stamps a provisional createdAt. The stored value is set by the DAO, as every DAO in this project does, truncated to the column's millisecond precision so a returned instance equals a reloaded one. The second one is used by the transformer when it transforms the entity to a domain model. Because the id is final since an earlier chapter, a DAO cannot fill it in afterwards, so it returns a new, persisted domain object and leaves the transient one untouched. Both constructors reject null for all three ids, because with no setters a null could never be repaired later.

The argument order is deliberate - Category comes first, matching every DAO method and the endpoint path categories/{id}/products. Three consecutive UUID parameters still compile when two are swapped, so the domain test constructs each variant with four distinct values and asserts every getter. It is a dull test, and it is the only thing standing between a transposed argument and a row that links the wrong ids.


The decision that was really about failure

When I started this chapter, the open question was how to handle transactions when a partial failure is expected. The choices looked like per-item REQUIRES_NEW against one transaction for the whole batch. I now think that framing was wrong, because the transaction boundary is a consequence of where failures are detected.

Inside a Hibernate session, a constraint violation leaves the persistence context unusable. That is why per-item REQUIRES_NEW looks attractive: it is the obvious way to keep going after a database-level failure. It has a cost that is easy to miss, each inner transaction needs its own connection while the outer one still holds the first, so a batch of a few hundred items under load is a direct path to an exhausted connection pool. Savepoints (NESTED) avoid the second connection, but they still pay one round trip per item and keep the per-item failure handling in the database. But almost every failure this operation can have is discoverable by reading first. An unknown product, a product the requester does not own, an archived product, and a pair that is already assigned all show up in a query before anything is written. The service classifies every submitted id up front, writes only the ones that passed, and runs it all in a single transaction.

Reading first opens a small gap: between the read and the insert, another request could archive a product, and the classification would be stale. A shared lock on the read would close it, but only until the batch commits, and nothing stops the same archive a millisecond later. The end state is identical, so the lock buys an ordering nobody observes and pays for it with UPDATEs waiting on a few hundred rows. The version is the same one the category side already gets: a product archived moments after it was classified leaves a link that nothing reads, until something cleans it up.

The one failure left is a race condition, where another request assigns the same pair between the read and the write. A unique constraint on (category_id, product_id) catches it, the flush fails, the whole batch rolls back, and the DataIntegrityViolationException becomes the 409 that Chapter 11's handler already produces. No new exception type and no REQUIRES_NEW. The DAO flushes at the end of saveAll, so the race fails at a known place that a test can reach, not at commit time somewhere outside the DAO.

This design also shaped the SQL. The repository I started from used unnest and ON CONFLICT DO NOTHING, which are PostgreSQL features, and this project runs on MySQL. The MySQL equivalent, INSERT IGNORE, is worse than it looks, because it also swallows truncation and not-null errors that have nothing to do with duplicates. The fast H2 tests would not run either statement. Reading first and writing only what is new needs nothing but plain JPQL, which behaves the same on both databases, and that is why the persistence tests stay representative.


One result per submitted id

The response contains every product the client submitted, in the order it submitted them. I model it as a sealed type with one record per outcome.

public sealed interface ProductAssignmentResult {

    record Assigned(UUID productId, CategoryAssignment assignment) implements ProductAssignmentResult { }
    record AlreadyAssigned(UUID productId) implements ProductAssignmentResult { }
    record ProductNotFound(UUID productId) implements ProductAssignmentResult { }
    record ProductArchived(UUID productId) implements ProductAssignmentResult { }
}
Enter fullscreen mode Exit fullscreen mode

I considered returning the exception that would have been thrown for each failed item, which keeps one source of truth for what "not found" means. I decided against it, because it constructs exceptions that are never thrown, each one capturing a stack trace, and because the web layer would then need the status mapping extracted out of GeneralExceptionHandler. A sealed type is also exhaustive in the controller's switch, so adding a fifth outcome later is a compile error until every consumer handles it.

An earlier version had ProductNotOwned also, and it answered a question nobody should be able to ask: submit any UUID and the response tells you whether a product with that id exists in someone else's account. Since categories are shared, other people's products genuinely are in there, which makes the leak a real one rather than a theoretical one. A product the requester does not own is reported as ProductNotFound, the same way a single GET on a foreign resource would answer. From the caller's side, a product it cannot see does not exist. ProductArchived does not reopen the gap, because it is only ever returned for products the requester owns.

ProductArchived is the one that nearly got missed. An archived category rejects the whole request, which was obvious enough, but an archived product is a different shape of the same rule. ARCHIVED is terminal for products: canTransitionTo returns false for everything, so the product will never be visible again, and a link to it is dead the moment it is written, so blocking it is the right thing to do. Failing the entire batch over is not, because the archived product is one item among many that may be perfectly valid. That is the line the whole design runs on, and it is worth stating plainly: a problem with the request fails the request, a problem with one product becomes one result.

A category that does not exist fails the entire request with the NotFoundException that we already have. An archived category fails it with a ConflictException, since archived means closed to new content - the platform has closed that branch of the taxonomy, and no seller may add to it. Those say something about the request as a whole. Everything about a single product belongs in the result. An already-assigned product counts as a success, mirroring the idempotent delete() from earlier chapters, so a client can resend a half-failed batch and converge. Duplicate ids in a request are reported once, at the position of their first occurrence.

The order of the checks matters for one case. An archived product that is also already assigned is reported as ProductArchived, not AlreadyAssigned, because the archive is what blocked it and the existing assignment is incidental. Archived products never reach the already-assigned query at all, which a test asserts directly.

The REST chapter will expose this as 207 Multi-Status, with one status per product, so a client never has to infer partial success from a 200. That mapping lives in the controller. The service result carries no HTTP status, because business logic should not know about the web. The design assumes a bounded batch, a single transaction and an IN clause are fine at a few hundred ids and a problem at a hundred thousand, so the request body needs a size cap, enforced as a Bean Validation constraint at the web boundary. The same bound matters for the insert. Without hibernate.jdbc.batch_size, saveAll sends one INSERT per row. The ids come from @UuidGenerator with version 7, assigned in memory before the statement runs, which is what makes batching possible at all: with an IDENTITY column the database assigns the key during the insert, so Hibernate has to execute each statement immediately and silently disables batching no matter what the setting says. The setting belongs in the application configuration once that module gets one.


One query, two facts

Classifying a product needs two things about it: who created it, and whether it is archived. An earlier version of this chapter had a findCreatorIds method returning Map<UUID, UUID>, which answered the first question only. Adding the archive check could have meant a second query for the status, or loading whole products and throwing most of each one away.

Instead the method widened into findAssignmentInfo, returning a small record:

public record ProductAssignmentInfo(UUID createdById, ProductStatus status) {
}
Enter fullscreen mode Exit fullscreen mode

The projection in ProductRepository selects a third column and nothing else, so the cost is the same single query it always was. The DAO translates ProductStatusEntity to the domain ProductStatus on the way out, the way every other enum crosses that boundary in this project. It is a narrow method shaped for exactly one caller, which is usually a smell, but here it is the point. A general-purpose loadAll(Collection<UUID>) would hydrate specifications, prices and names to answer two questions, on a path that may be classifying several hundred ids at once.


Unassigning, and what deleting a parent means

Only assignment is a bulk operation. Removing a product from a category is a single DELETE on one pair, and it needs no per-item result. A missing pair is a quiet no-op, so the endpoint answers 204 either way. The category must exist, so a client mistake still produces a 404, but ownership is checked on the product, not the category: the taxonomy is shared, and what a seller may remove is their own product's place in it. The category may be archived, because blocking removal would trap products in a branch nobody can edit. An archived product can be unassigned too, for the same reason - the rule is about adding dead links, not keeping them.

Deleting a product or a category must also delete its assignments, and I wanted that rule written in Java, not hidden in a migration. The two overridden delete methods in ProductsServiceImpl and CategoriesServiceImpl call super.delete first and then clear the assignments through the DAO. The order matters, because an unauthorized caller gets its exception before anything is touched. A missing parent still triggers the cleanup, so a retried delete heals an orphan. The override carries its own @Transactional. Spring would find the one on the superclass method anyway, but the composite operation is defined here, and its transaction boundary should be visible in the same place.

On the product side, I also kept a foreign key with ON DELETE CASCADE in the future Flyway script, and I want to be plain about what that does. It is a backstop for one race: a product deleted between the existence read and the insert makes the insert fail, which becomes the same 409 as a duplicate. It is not the mechanism. The Java cleanup is the mechanism, and the H2 tests prove it. In production, MySQL cascades first, which would mask a regression in the Java cleanup, so those tests are the only thing that would catch one. The category side has no foreign key. Category deletion may one day arrive as an event from another service, and a constraint into another database cannot exist anyway. The consequence is an orphan window: a category deleted mid-batch can leave a dangling link that nothing reads, until a retried delete clears it. The design is not airtight, and I would rather state that than imply it.

The assignment feature also touches neither ProductEntity nor CategoryEntity, which hold no associations to it. If you copy this project as a template and do not need categories, you can delete the assignment feature without editing either of them. The one leftover is findAssignmentInfo on ProductDao.


The tests that found two bugs

The category search specification had a bug that no test could see. When the boolean active flag became a status enum, the specification kept calling root.get("active") and CategorySort kept an ACTIVE("active") key. Everything compiled, the service tests passed, because the DAO is mocked there. AbstractSearchableTestCase never sets a filter, so the specification was never reached. Only a test that runs a real query against H2 can see this kind of failure, because the property name is a string that no compiler checks. The fix was to replace the boolean filter with a CategoryStatus and to rename the sort key. The lasting part is the test, a parameterised test over every CategorySort value, and another over every CategoryStatus, means a future enum value is covered without anyone remembering to add a test. Those tests would have caught this bug the day it was introduced.

Writing them exposed a second bug in the shared SearchableDaoImpl. A caller that sets a sort key and leaves the direction null reaches a switch on a null enum, which throws a NullPointerException before any branch runs, even though a comment claimed the default branch handled it. One line fixes it, case null, default -> Sort.Direction.DESC, and a test asserting the resulting order guards it. Both bugs fail inside the data access layer, which is exactly where mocked service tests never look.


What these tests cannot prove

Three limits are worth knowing about. @DataJpaTest wraps every test in a transaction, which silently satisfies the @Modifying delete's need for one, so none of these tests would catch a service that forgets to open a transaction. The duplicate-pair test must be the last statement in its method, because a failed flush poisons the session, and it cannot show that a whole batch rolled back. And the foreign key never appears in H2 at all, because the schema there comes from Hibernate and the entity holds plain UUID columns. All three are a future chapter's job, against a real MySQL.


One simplification, stated up front

There is no role model yet, so the check that should ask whether the caller may curate categories asks whether they created one instead - CategoriesServiceImpl.authorize compares the requester with the creator, which stands in for a role that does not exist. The service assumes the caller reached it legitimately, and CategoryAssignmentsService never asks who owns a category because the answer is always the platform. When identity arrives from IDP later in the series, the category write operations gain a role check and this service does not change at all. That is the shape you want from the boundary: the rule that moves is the one about who may call, not the one about what the call does.


What's Next?

Everything in this chapter is a plain class with a constructor. Nothing declares a bean, nothing scans for entities, and the only place the pieces are wired together is a test. A REST layer needs an application to live in first.

Chapter 13 assembles one, in the application module and nowhere else. Every bean comes from a factory method there, because an @Service on CategoryAssignmentsServiceImpl would make the business logic module a Spring module, and the eleven chapters spent keeping it free of the container would have bought nothing. The same question returns for @EntityScan and @EnableJpaRepositories, which have to reach packages in another module without that module knowing it is being scanned. And hibernate.jdbc.batch_size, deferred twice in this chapter, finally has somewhere to go.

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 12, use the following link:

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 11: Error Handling & Domain Exceptions Across Module Boundaries
▶️ Read Chapter 13 (Coming soon)


Join the Masterclass Journey

This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!


Recommended Resources for Java & Spring Boot Engineers

Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.

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):

Top comments (0)