DEV Community

Kamen
Kamen

Posted on • Originally published at kamenivanov.substack.com

The Dao Implementation Layer (Chapter 6)

The last 11 years through my career, I've seen codebases that suffered from a silent architectural decay. Check any corporate monolith or a microservice and you will find Spring Data repositories, JPA entity annotations, database specifics, and pagination parameters leaking straight into business services, controllers, and domain models. Developers call it "rapid development." What they've actually built is a tightly coupled codebase that is nearly impossible to support, maintain, and bugfix - especially if there are no tests. And guess what? There are none. Because the code is so tightly coupled, developers don't have the time, or honestly the patience, to mock all those dependencies or try to disentangle the mess just to make their lives easier. Divide and conquer - it's the rule I live by whenever I'm building a new service, REST endpoint, or Kafka integration.

In Chapter 2 and Chapter 3, we drew a hard boundary. We established a 100% pure Java domain model completely free of database metadata, and we defined clean DAO API contracts. In Chapter 4, we've shown why we use direct, JIT-optimized Java transformers instead of reflection-based mapping like MapStruct, and in Chapter 5, we explained why we don't even allow Lombok as a dependency.

Now, it's time to open up the engine room. We are implementing the dao-impl infrastructure module using Java 25, Spring Data JPA, and Hibernate. We will look at how generic hierarchy extension and custom Specification execution keep our persistence details locked safely away from the rest of the application.


CrudDaoImpl and SearchableDaoImpl

Writing boilerplate CRUD and search logic for every single database table is a waste of resources and time. At the same time, copy-pasting generic utility code creates maintenance nightmares. We solve this by structuring a clean, type-safe inheritance hierarchy.

At the base sits CrudDaoImpl. It handles standard entity creation, lifecycle validation, and ID lookups while enforcing the boundary between domain objects and database entities:

public abstract class CrudDaoImpl<
        IdType,
        Domain extends AbstractCreatable<IdType>,
        Entity extends AbstractCreatableEntity,
        Repo extends CrudRepository<Entity, IdType>
    > implements CrudDao<IdType, Domain> {

    protected final Validator validator;
    protected final Repo repository;
    protected final BiTransformer<Entity, Domain> transformer;

    protected CrudDaoImpl(
        Repo repository,
        BiTransformer<Entity, Domain> transformer,
        Validator validator
    ) {
        this.repository = repository;
        this.transformer = transformer;
        this.validator = validator;
    }

    @Override
    public Domain create(Domain domain) {
        if (domain == null) {
            throw new IllegalArgumentException("Entity is mandatory");
        }
        if (domain.getId() != null) {
            throw new IllegalArgumentException("Entity existing!");
        }

        var entity = transformer.createInput(domain);
        preCreate(entity);
        validateEntity(entity);
        entity = repository.save(entity);

        return transformer.createOutput(entity);
    }

    protected void preCreate(Entity entity) {
        final var now = Instant.now();
        entity.setCreatedAt(now);

        if (entity instanceof AbstractUpdatableEntity updatableEntity) {
            updatableEntity.setUpdatedAt(now);
        }
    }

    protected void preUpdate(Entity entity) {
        if (entity instanceof AbstractUpdatableEntity updatableEntity) {
            updatableEntity.setUpdatedAt(Instant.now());
        }
    }
    // Additional shared CRUD machinery...
}
Enter fullscreen mode Exit fullscreen mode

Notice preCreate and preUpdate reach for instanceof instead of calling setUpdatedAt() directly on Entity. That's deliberate, not a gap. CrudDaoImpl has to stay generic over every entity in the hierarchy, including append-only ones like CategoryAssignment that never extend AbstractUpdatableEntity at all - so Entity can only ever be statically bounded by AbstractCreatableEntity, and the compiler genuinely has no way to know, from inside this class, whether a given Entity also supports updatedAt.

The alternative would be a parallel UpdatableDaoImpl layer bounded by AbstractUpdatableEntity, sitting next to SearchableDaoImpl. That sounds cleaner until you remember Java only allows single inheritance: ProductDaoImpl already extends SearchableDaoImpl, so it couldn't also extend a second base class for updatability without either duplicating the search machinery into a combinator class or moving the whole hierarchy to interfaces with default methods. One instanceof check that anyone can read top to bottom is a smaller price than that.

It's also worth being precise about why this isn't the reflection-based magic we spent earlier chapters arguing against: instanceof is a single, explicit, statically-checked type test that the compiler verifies at compile time and the JIT can inline - nothing here is scanning annotations, generating bytecode, or hiding behind a target/generated-sources folder. Runtime type inspection and reflection-based mapping look similar on the surface, but they aren't the same tool, and conflating them would undercut the whole argument this series has been making.

preCreate captures a single now and reuses it for both fields rather than calling Instant.now() twice, so createdAt == updatedAt on a fresh insert is guaranteed exactly, not just approximately - which is what actually makes that equality useful as a "never touched since creation" signal.


Why timestamps are set here, not left to the database

The Flyway migration for this table will have a DEFAULT CURRENT_TIMESTAMP on the created_at and updated_at columns, so it's worth asking directly: if the database is already going to fill it in, why does preCreate and preUpdate set it again in application code? Because the database default and the application value are solving two different problems.

The DB default is a safety net. It exists for anything that touches the table outside the application: a manual INSERT during an incident, a backfill script, another service hitting the same schema directly. It guarantees the column is never null, no matter what wrote the row.

The application-level value is the source of truth for anything going through this DAO, and setting it explicitly in preCreate matters for a concrete reason: Hibernate doesn't automatically read back database-computed defaults after save(). Relying on the DB default alone means the in-memory entity still has createdAt == null right after the insert, and create() hands that same object straight to transformer.createOutput(entity) - so the caller gets back a Product with a null creation timestamp for an entity that very much has one in the database. That's a real bug, not a style nitpick.

The same reasoning is why preCreate sets updatedAt too, not just createdAt. updated_at is NOT NULL, same as created_at, so it can't be left unset until the first real update without hitting the exact null-after-save problem above - except this time the DB default that would normally paper over it isn't guaranteed to fire at all, since CURRENT_TIMESTAMP defaults are typically written to apply on insert, not on every column independently. Setting both fields from a single captured now sidesteps that entirely, and gets you createdAt == updatedAt as a free signal that a record has never been touched since creation, with no extra flag to maintain.


Immutable Audit Fields on the Domain Side

The DAO layer owning createdAt/updatedAt only works if the domain side agrees to stay out of the way. Earlier versions of our domain classes had default no-arg constructors and public setters, which left audit fields mutable and gave the business layer a path to overwrite them by accident.

We removed both. Domain classes no longer have no-arg constructors, and createdAt/updatedAt are final - set once, via the reconstitution constructors covered in Chapter 2, and never touched again for the life of that object. Our transformers reflect this split cleanly: writing domain-to-entity, createInput() ignores audit fields entirely, since preCreate/preUpdate above already own them on the entity side; reading entity-to-domain, createOutput() is the only caller that ever passes a real createdAt/updatedAt into a domain constructor, because it's the only place that has the real, already-persisted values to pass. No service layer and no client payload has a path to either field.

When a domain requires complex search capabilities, pagination, and sorting, we extend CrudDaoImpl with SearchableDaoImpl. Instead of letting business services deal with JPA Criteria APIs or Spring Data Specification objects, the searchable DAO encapsulates query execution internally:

public abstract class SearchableDaoImpl<
        IdType,
        Domain extends AbstractCreatable<IdType>,
        Entity extends AbstractCreatableEntity,
        Repo extends CrudRepository<Entity, IdType> & JpaSpecificationExecutor<Entity>,
        Params extends AbstractParams<? extends SortBy>
    > extends CrudDaoImpl<IdType, Domain, Entity, Repo> implements SearchableDao<Domain, Params> {

    protected static final Sort ID_DESC_SORT = Sort.by(Sort.Direction.DESC, "id");
    protected static final Sort CREATED_AT_DESC_SORT = Sort.by("createdAt").descending().and(ID_DESC_SORT);

    protected SearchableDaoImpl(
        Repo repository,
        BiTransformer<Entity, Domain> transformer,
        Validator validator
    ) {
        super(repository, transformer, validator);
    }

    @Override
    public ResultPage<Domain> search(Params params) {
        final Pageable pageable = toPage(params);
        final Specification<Entity> spec = createSpecification(params);
        final Page<Entity> result = repository.findAll(spec, pageable);
        final List<Domain> content = result.getContent()
                .stream()
                .map(transformer::createOutput)
                .toList();

        return new ResultPage<>(
            result.getTotalPages(),
            result.getTotalElements(),
            content
        );
    }

    protected abstract Specification<Entity> createSpecification(Params params);
}
Enter fullscreen mode Exit fullscreen mode

Why Repo needs two bounds, not one

Look closely at the type bound on SearchableDaoImpl:

Repo extends CrudRepository<Entity, IdType> & JpaSpecificationExecutor<Entity>
Enter fullscreen mode Exit fullscreen mode

That & is an intersection type, and it's doing more work than it looks like. CrudRepository gives us save(), findById(), delete() - the basic CRUD operations that CrudDaoImpl needs. But CrudRepository has no idea what a Specification is, that interface lives entirely in JpaSpecificationExecutor, which is what actually declares findAll(Specification<T>, Pageable).

If we bounded Repo on CrudRepository alone, the search() method above simply wouldn't compile - repository.findAll(spec, pageable) calls a method that doesn't exist on that interface. If we bounded it on JpaSpecificationExecutor alone, we'd lose save() and the rest of the CRUD surface that CrudDaoImpl depends on. We need both interfaces satisfied by the same concrete repository type, and Java's intersection bounds are the only clean way to express "this generic parameter must implement A and B" without collapsing the two responsibilities into one bloated interface.

The payoff shows up downstream, in ProductRepository and CategoryRepository, they simply extend both interfaces, and every method from both becomes available on repository inside the DAO, fully typed, with no casting. If any entity doesn't need to be paginated, it will simply extend only the CrudRepository.

Notice also how sorting is handled above. By appending id DESC as a deterministic tie-breaker to every sort order, we eliminate unstable pagination results when multiple records share identical timestamps or property values. I've chased that exact bug in production before - page 3 of a listing quietly returns a row you already saw on page 2, because two rows had the same createdAt millisecond and the database made no promises about their relative order.


ProductDaoImpl and CategoryDaoImpl

With the generic abstraction we just created, implementing concrete data access objects requires minimal code. Look at how clean CategoryDaoImpl is:


public class CategoryDaoImpl extends SearchableDaoImpl<
        UUID,
        Category,
        CategoryEntity,
        CategoryRepository,
        CategorySearchParams
        > implements CategoryDao {

    public CategoryDaoImpl(CategoryRepository repository, Validator validator) {
        super(repository, CategoryTransformer.instance, validator);
    }

    @Override
    protected Specification<CategoryEntity> createSpecification(CategorySearchParams params) {
        return (root, query, cb) -> {
            final List<Predicate> predicates = new ArrayList<>();

            if (params.getName() != null && !params.getName().isBlank()) {
                predicates.add(cb.like(cb.lower(root.get("name")), "%" + params.getName().toLowerCase() + "%"));
            }

            if (params.getActive() != null) {
                predicates.add(cb.equal(root.get("active"), params.getActive()));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

For domain-specific queries like SKU lookups, ProductDaoImpl delegates directly to custom repository methods while keeping the transformation logic fully explicit:

public class ProductDaoImpl extends SearchableDaoImpl<
        UUID,
        Product,
        ProductEntity,
        ProductRepository,
        ProductSearchParams
    > implements ProductDao {

    public ProductDaoImpl(ProductRepository repository, Validator validator) {
        super(repository, ProductTransformer.instance, validator);
    }

    @Override
    public Product loadBySku(String sku) {
        final var product = repository.loadBySku(sku);
        return transformer.createOutput(product);
    }

    @Override
    protected Specification<ProductEntity> createSpecification(ProductSearchParams params) {
        return (root, query, cb) -> {
            final List<Predicate> predicates = new ArrayList<>();

            if(params.getName() != null && !params.getName().isBlank()){
                predicates.add(cb.like(cb.lower(root.get("name")), "%" + params.getName().toLowerCase() + "%"));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Why I don't use Spring Data Method Naming

Look at ProductRepository, or any custom repository method in this codebase, and you won't find something like findByProductStatusAndNameContainingIgnoreCaseAndCreatedAtAfterOrderByPriceDesc. Spring Data will happily generate that query for you from the method name alone - no annotation, no SQL, just string parsing at startup. It seems easy and intuitive, but when we get in a production service taking real traffic, I don't want it anywhere near my persistence layer, for a few concrete reasons.

The first is fragility, if we rename a field on the entity, let's say for example sku becomes productSku, and the derived-name query built on the old name either breaks at startup with a PropertyReferenceException, or, depending on how the method's structured, doesn't break at all and just silently stops matching what you think it matches. Either way, the compiler never told you. An explicit query, by contrast, references the actual JPQL or SQL, so a rename is a normal refactor with a normal compile error, not a runtime surprise waiting for the right request to trigger it.

The second is readability, and the method name above is the proof, once a query needs more than two or three conditions, the method name stops being a name and becomes a run-on sentence encoding your entire WHERE clause in camelCase. Nobody reads that and understands the query faster than they'd understand the five lines of JPQL it's standing in for - they read it slower, and they have to mentally decode ContainingIgnoreCase and OrderByPriceDesc back into SQL concepts they already know.

The third is the one that actually matters most at midnight during an incident: derived queries hide the generated SQL from you until it runs. An explicit @Query shows you the exact projection, the exact joins, the exact fetch strategy, right there in the repository interface, during code review, before it had the chance to quietly become an N+1 problem in production.

ProductRepository's loadBySku makes this concrete:

public interface ProductRepository extends CrudRepository<ProductEntity, UUID>, JpaSpecificationExecutor<ProductEntity> {

    @Query("SELECT p FROM ProductEntity p WHERE p.sku = :sku")
    ProductEntity loadBySku(@Param("sku") String sku);
}
Enter fullscreen mode Exit fullscreen mode

Spring Data could derive this one from a method named findBySku without any annotation at all, it's simple enough. I write the @Query anyway, because the moment this method needs a join or a second condition, I want it to already look like every other query in this codebase, not like a special case that started simple and grew a name nobody can parse at a glance.


What's Next?

We've built a flexible persistence infrastructure: clean generic DAO hierarchies, explicit Java transformers, and validation that fails fast in application code instead of at the database.

Spoiler alert: a green Spring integration test does not necessarily prove that an entity can be written and hydrated correctly by the database. In Chapter 7, we expose Hibernate’s write-behind cache and dirty checking. When a test’s purpose is to verify database constraints or fresh hydration, it should intentionally use .flush() and .clear() at the relevant boundary rather than accidentally read managed state from the persistence context.

See you in the next chapter.


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 6, 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 5: The Lombok Illusion
▶️ Read Chapter 7: The Persistence layer Testing (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!

Top comments (0)