Kang YiKai

The Journey and Reflections on Refactoring Simple Use Case

· 19 min read

In the previous post, the core theme we dealt with was ModuleStatus, and we discussed a simple example. This example contained two Use Cases: one for updating data and another for resetting data. The former is triggered via HTTP, while the latter is triggered via Events. The main logical difference between the two is that the former requires fetching existing data from an external service before storage, whereas the latter simply stores the data directly.

After the last post was published, I received feedback from colleagues and had a discussion with them. Following that discussion, I realized there were some issues with the previous post: not only was the definition of Use Cases insufficiently clear, but there was also ambiguity and confusion regarding classic DDD (Domain-Driven Design) and Clean Architecture concepts.

Therefore, I plan to perform a relatively complete refactoring of the current Implementation to gain a clearer understanding of it myself. This journey will start from the core Domain, pass through several Use Cases (one of which involves interaction with external systems and is relatively complex, so I will examine it as a key point), and finally end with how to invoke these Use Cases.

Looking at the file directory structure, I have divided it into three parts:

  • Application: Responsible for application logic, primarily containing Use Cases and Ports.
  • Domain: Where the core logic resides, containing only the most basic algorithms and independent of any external technical implementation.
  • Infrastructure: Primarily responsible for the Adapter implementations of Ports—i.e., various external adapters that depend on the Ports in the Application.

Here is the complete architecture map. Don’t worry, we will build it from scratch.

.
├── application
│   ├── exception
│   │   └── ModuleStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── ModuleStatusQueryPort.java
│   │   └── outbound
│   │       ├── ModuleStatusRepositoryPort.java
│   │       ├── RegistryModulePort.java
│   │       ├── EventServicePort.java
│   │       └── TenantDirectoryPort.java
│   └── usecase
│       ├── ModuleStatusQueryUseCase.java
│       ├── ResetModuleStatusUseCase.java
│       └── UpdateModuleStatus.java
├── domain
│   ├── exception
│   │   ├── EnterpriseModuleNotFoundDomainException.java
│   │   ├── ModuleNotFoundDomainException.java
│   │   └── TenantNotFoundDomainException.java
│   ├── model
│   │   ├── EnterpriseModuleStatus.java
│   │   ├── TenantModuleStatuses.java
│   │   └── TenantModuleStatus.java
│   └── service
│       └── ModuleStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── EnterpriseModuleStatusResource.java
        │       └── dto
        │           └── TenantModuleStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── RegistryModuleToEnterpriseModuleStatusMapper.java
            ├── RegistryModuleAdapter.java
            ├── EventServiceAdapter.java
            └── TenantDirectoryAdapter.java

Creating the Domain and Simple Use Cases

In the beginning, data structure is the starting point for everything. I remember someone once said, “Software system = Data Structure + Algorithm.” A recent blog post I browsed also mentioned that data structure determines the shape of the product.

Therefore, the first thing we need to do is create the data models and structures. Undoubtedly, this belongs to the Model within the Domain. Here is the code for the data model: the Module ID and its status form the most basic Status, while Statuses contain a list of Statuses and a tenant identifier (TenantId).

package com.example.modulesystem.domain.model;

public record TenantModuleStatus(@NonNull ModuleId moduleId, Boolean isActive) {
    public TenantModuleStatus withStatusReset(){
        return new TenantModuleStatus(this.moduleId(), false);
    }
}
package com.example.modulesystem.domain.model;

public record TenantModuleStatuses(@NonNull TenantId tenantId, @NonNull List<TenantModuleStatus> tenantModuleStatus) {

    public Optional<TenantModuleStatus> getModuleStatusFor(
            @NonNull final ModuleId moduleId) {
        return tenantModuleStatus.stream()
                .filter(fs -> fs.moduleId().toString().equalsIgnoreCase(moduleId.toString()))
                .findFirst();
    }
}

The corresponding Database Entity and Table have already been created in advance.

Next, we need to define the Repository interface. Although its structure is simple and fits the templated CRUD (Create, Read, Update, Delete) pattern, the key is: It defines what interfaces I need to use within the Use Case. Interfaces and their corresponding implementations usually contain a lot of boilerplate code, so only the Port part is listed here, ignoring specific implementation details.

package com.example.modulesystem.application.port.outbound;

public interface ModuleStatusRepositoryPort {
    TenantModuleStatuses findByTenantId(@NonNull final TenantId tenantId);

    boolean save(@NonNull final TenantModuleStatuses tenantModuleStatuses);
}

In Clean/Hexagonal Architecture, the Repository Port belongs to the Application Outbound layer; it exists to serve the Use Case. As the leader of the business logic, the Application explicitly knows what services and tools it needs from the external world to implement specific business logic. It does not care about specific implementation details, nor does it care about the source or acquisition method of the data.

Having prepared the Port for interacting with the database, we can now start from a Product Manager’s perspective and create the most basic Use Cases in the Application. Here are two simple examples:

The first Use Case fetches data directly from the database, assuming no logging is required.

package com.example.modulesystem.application.usecase;

public class ModuleStatusQueryUseCase {

    private final ModuleStatusRepositoryPort moduleStatusRepository;

    public ModuleStatusQueryUseCase(final ModuleStatusRepositoryPort moduleStatusRepository) {
        this.moduleStatusRepository = moduleStatusRepository;
    }

    public TenantModuleStatuses apply(@NonNull final TenantId tenantId) {
        return moduleStatusRepository.findByTenantId(tenantId);
    }
}

The second Use Case is slightly more complex, but still only relies on the Repository Port: read data from the database, reset the status, and save it back to the database.

The Use Case controls data via the Port and does not need to—nor should it—know any details of the underlying implementation.

package com.example.modulesystem.application.usecase;

@Slf4j
public class ResetModuleStatusUseCase {
    private final ModuleStatusRepositoryPort moduleStatusRepository;

    public ResetModuleStatusUseCase(final ModuleStatusRepositoryPort moduleStatusRepository) {
        this.moduleStatusRepository = moduleStatusRepository;
    }

    public void apply(@NonNull final TenantId tenantId) {
        log.info("Invoking factory reset of module statuses for TenantId={}", tenantId);
        final TenantModuleStatuses currentStatuses = moduleStatusRepository.findByTenantId(tenantId);
        final TenantModuleStatuses resetStatuses = new TenantModuleStatuses(
                currentStatuses.tenantId(),
                currentStatuses.tenantModuleStatus()
                        .stream()
                        .map(TenantModuleStatus::withStatusReset)
                        .toList()
        );
        moduleStatusRepository.save(resetStatuses);
    }
}

At this point, we face a question: Suppose we have a Controller or Resource that needs to query data from the database. Can it connect directly to the Repository Port via the Controller, bypassing the Use Case? The answer is no. Because the Controller, located in the Inbound Adapter layer, is responsible for translating external calls. It is like a front-desk employee; they cannot run to the data center to retrieve data without the approval of a middle manager (Use Case). Strictly speaking, even very simple logic should belong to a specific Case and should not be allowed to use a “backdoor” just because it is simple.

This is the current directory tree structure:

.
├── application
│   ├── port
│   │   └── outbound
│   │       └── ModuleStatusRepositoryPort.java
│   └── usecase
│       ├── ModuleStatusQueryUseCase.java
│       └── ResetModuleStatusUseCase.java
└── domain
    └── model
        ├── TenantModuleStatus.java
        └── TenantModuleStatuses.java

The third Use Case is the most complex, but also the closest to reality. We need to complete the following steps in order:

  1. Get the TenantId from the input parameters.
  2. Ensure the TenantId exists via the external TenantDirectory service.
  3. Get the RegistryModule list from the external RegistryLookup and RegistryModuleService, and determine if the Modules exist in this list.
  4. Determine if the Modules belong to the Enterprise type.
  5. Transform the data type and store it.
  6. Call the external EventService to trigger cache invalidation.

The original core code briefly describes this process:

public void apply(final ModuleStatusModel moduleStatusModel) {
        // Step 1
        final TenantId tenantId = moduleStatusModel.tenantId();
        // Step 2
        guardTenantExists(tenantId);
        // Step 3
        final List<RegistryModule> registryModules = lookupModulesForRegistry(tenantId, getContext());
        guardModuleExists(moduleStatusModel, registryModules);
        // Step 4
        guardModuleIsEnterprise(moduleStatusModel, registryModules);
        // Step 5
        final ModuleStatusModel mappedModuleStatusModel = mapModuleIdToMatchRegistry(moduleStatusModel, registryModules);
        final boolean stateHasChanged = moduleStatusRepository.save(mappedModuleStatusModel);
        // Step 6
        if (stateHasChanged) {
            log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
            eventService.invalidateCacheForTenants(List.of(tenantId.toString()));
        }
    }

Deep Dive and Breakdown of the Use Case

Step 1 is to get the TenantId from the input parameters. It is very simple, so we can skip directly to Step 2. The TenantId type defaults to a core type defined by the system, so I won’t elaborate on it here.

final TenantId tenantId = moduleStatusModel.tenantId();

Step 2

Step 2 requires checking whether the TenantId exists via the external system TenantDirectory. The original method is as follows:

private void guardTenantExists(final TenantId tenantId) {
    final boolean tenantExists = tenantDirectory.tenantExists(tenantId);

    if (!tenantExists) {
        log.warn("No tenant found for id '{}'", tenantId);
        throw ModuleStatusApiException.unknownTenant(tenantId);
    }
}

This code looks simple, but it fuses three distinct meanings:

  • Infrastructure: Specific technical control, e.g., InternalDriver.controlRouting.
  • Outbound Port: Calling external dependencies, e.g., tenantDirectory.tenantExists.
  • Domain: Business rules, i.e., the tenant must exist if(!tenantExists).

We need to split it up.

First, isolate the Domain logic. As the Domain, it shouldn’t know how to query, nor should it care about logging. It has no dependency on the outside world and knows no technology stack; it only knows “If it doesn’t exist, throw an error.” Following this train of thought, the simplified logic is:

package com.example.modulesystem.domain.service;

public class TenantValidator {
    public void guardTenantExists(final TenantId tenantId, final boolean exists) {
        if (!exists){
            throw new TenantNotFoundDomainException(tenantId);
        }
    }
}

And the Exception needed here:

package com.example.modulesystem.domain.exception;

public class TenantNotFoundDomainException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final TenantId tenantId;
    public TenantNotFoundDomainException(final TenantId tenantId) {
        super("No tenant found for TenantId: "+tenantId.toString());
        this.tenantId = tenantId;
    }

    public TenantId getTenantId() {
        return tenantId;
    }
}

A specific Exception should be created specifically for the Domain, which is then caught in the Use Case. The purpose of doing this is to decouple the Domain from the outside world. To put it simply, even if we delete the Application and Infrastructure parts, the IDE will not report errors, and the compilation will pass. This is what we know as dependency management—the Domain should not depend on the external world.

Next, we expand outward to create the external capability needed by the Use Case, namely TenantDirectory. It can pass the boolean value of exists into the internal system, and finally hand it over to the Domain for judgment. To do this, we need to define an Outbound Port in the Application. This Port should be defined by the internal Application, not the external one.

package com.example.modulesystem.application.port.outbound;

public interface TenantDirectoryPort {
    boolean tenantExists(TenantId tenantId);
}

The implemented Adapter is located in the outermost Infrastructure layer. It depends on the Port defined internally and imports external libraries, working together to implement the functions required internally.

package com.example.modulesystem.infrastructure.adatper.outbound;

public class TenantDirectoryAdapter implements TenantDirectoryPort {

    private final TenantDirectory tenantDirectory;

    public TenantDirectoryAdapter(final TenantDirectory tenantDirectory) {
        this.tenantDirectory = tenantDirectory;
    }

    @Override
    public boolean tenantExists(final TenantId tenantId) {
        // Simulating technical implementation details
        try (var rc = InternalDriver.controlRouting(
                InternalDriver.READ_ONLY, getClass().getName())) {
            return tenantDirectory.tenantExists(tenantId);
        }
    }
}

Looking back at the previous code, we find there is also a logging logic: log.warn("No tenant found for id '{}'", tenantId);. Where should it be placed? Since the Domain only expresses core rules (i.e., failure if rules are not met) and does not care about specific recording, logging can be handed over to the Use Case or Infrastructure.

Here, I understand it as recording the state of the external system, so I add log.debug("Checked tenant existence for ID {}: {}", tenantId, exists); in the Infrastructure as a technical log.

Or you can choose to add it to the Use Case.

The complete code for this step in the Use Case is:

// Step 2 UseCase
final boolean exists = tenantDirectoryPort.tenantExists(tenantId);
try{
 tenantValidator.guardTenantExists(tenantId, exists);
} catch (final TenantNotFoundDomainException ex) {
 log.warn("No tenant found for id '{}'", tenantId);
 throw ModuleStatusApiException.unknownTenant(tenantId);
}

Step 3

First, look at the original code, which is divided into two parts: first get RegistryModule, and then call guardModuleExists to compare with the input parameters.

final List<RegistryModule> registryModules = lookupModulesForRegistry(tenantId, getContext());
guardModuleExists(moduleStatusModel, registryModules);

Note that we have introduced the external model RegistryModule. To avoid introducing information irrelevant to the Domain, or to avoid “polluting” the system, we first need to define a data type exclusive to the Domain.

package com.example.modulesystem.domain.model;

public record EnterpriseModuleStatus(@NonNull ModuleId moduleId, @NonNull boolean isEnterprise) {
}

Then we need a Mapper that can convert the external RegistryModule into the internal data model.

I put it in infrastructure.outbound because it represents the conversion of external data to internal data, and this conversion happens at the edge of Infrastructure -> Application. I interpret the “edge” as akin to a return position or a method input parameter.

package com.example.modulesystem.infrastructure.adatper.outbound.mapper;

public class RegistryModuleToEnterpriseModuleStatusMapper {

    public static List<EnterpriseModuleStatus> toDomain(final List<RegistryModule> registryModules) {
        return registryModules
                .stream()
                .map(module -> new EnterpriseModuleStatus(
                        ModuleId.of(module.getModuleId()),
                        hasEnterpriseTag(module)
                )).toList();
    }

    private static boolean hasEnterpriseTag(final RegistryModule module) {
        return module.getTags().stream().anyMatch(tag -> tag.equalsIgnoreCase(ModuleTag.IS_ENTERPRISE.value()));
    }
}

With the internal data type and converter, we can create a Port in the Application’s Outbound to get EnterpriseModuleStatus (or RegistryModule), specifically for Use Case usage. The implementation of this Port, because it is not the focus of the Application, can be dumped entirely into the Infrastructure Adapter.

This is what is often called Dependency Inversion: high-level modules do not depend on the external implementation’s Infrastructure but only on abstract Ports, whereas the Infrastructure must depend on the Ports.

First, we define a Port:

package com.example.modulesystem.application.port.outbound;

public interface RegistryModulePort {
    List<EnterpriseModuleStatus> findByTenantId(TenantId tenantId);
}

Then, the Adapter implementing this Interface is created. Most of the code can be copied directly. You don’t have to read the full details, but take a look at the conversion part at the end of the method, which converts the external RegistryModule into EnterpriseModuleStatus.

package com.example.modulesystem.infrastructure.adatper.outbound;

@Slf4j
public class RegistryModuleAdapter implements RegistryModulePort {

    private final EventService eventService;
    private final RegistryLookup registryLookup;
    private final RegistryModuleService registryModuleService;

    public RegistryModuleAdapter(
            final EventService eventService,
            final RegistryLookup registryLookup,
            final RegistryModuleService registryModuleService){
        this.eventService = eventService;
        this.registryLookup = registryLookup;
        this.registryModuleService = registryModuleService;
    }

    // Omitting specific context building methods...

    @Override
    public List<EnterpriseModuleStatus> findByTenantId(final TenantId tenantId) {
        // Simulating context
        final var context = new Object();

        log.debug("Fetching RegistryModules for TenantId={}", tenantId);
        final List<RegistryInfo> registries = registryLookup.getRegistriesFor(tenantId, null, context);
        if (registries.isEmpty()) {
            return Collections.emptyList();
        }

        final List<RegistryModule> associatedModules = new ArrayList<>();

        for (final RegistryInfo registry : registries) {
            final Optional<RegistryModules> modulesFromCache = Optional.ofNullable(
            registryModuleService.findUnfilteredByRegistryIdCached(registry.getRegistryId()));
            final RegistryModules modules = modulesFromCache.orElseGet(
                    () -> registryModuleService.findUnfilteredByRegistryId(registry.getRegistryId()));
            if (modules != null) {
                associatedModules.addAll(modules.getAssociatedModules());
            } else {
                log.debug("No Modules for registry '{}' found", registry.getRegistryId());
            }
        }

        return RegistryModuleToEnterpriseModuleStatusMapper.toDomain(associatedModules);
    }
}

With the Port and the actual Adapter, we can finally start building the logic for the Use Case and Domain. The core logic is: we need to ensure that the input modules match the existing modules. If a module does not exist at all, an error is thrown directly.

package com.example.modulesystem.domain.service;

public class ModuleGuard {
    public void guardModuleExists(
            final TenantModuleStatuses tenantModuleStatuses,
            final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses) {

        final List<String> moduleIds = enterpriseModuleStatuses.stream()
                .map(moduleIsEnterprise -> moduleIsEnterprise.moduleId().toString())
                .toList();

        final List<String> missingIds = tenantModuleStatuses.tenantModuleStatus().stream().map(
                dfs -> dfs.moduleId().toString().toLowerCase()
        ).filter(dfs -> !moduleIds.contains(dfs)).toList();

        if(moduleIds.isEmpty() || !missingIds.isEmpty()) {
            throw new ModuleNotFoundDomainException(tenantModuleStatuses.tenantId(), missingIds);
        }
    }
}

Finally, the Use Case utilizes this logic:

final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
try {
 moduleGuard.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
} catch (final ModuleNotFoundDomainException ex) {
 throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
}

Similarly, for Exceptions, simply define them in the layer they belong to:

  • ModuleNotFoundDomainException is located in domain.exception.
  • ModuleStatusApiException is located in application.exception.

To summarize the process of Step 3:

  1. Define Domain Models and Mappers to ensure correct data format.
  2. Define Ports and Adapters to ensure external data can enter.
  3. Prepare Domain logic and add it to the Use Case.

Step 4

In this step, we need to determine if the module category belongs to “Enterprise”. We can see that the original code relied heavily on external data structures:

private static void guardModuleIsEnterprise(
            final TenantModuleStatusesModel moduleStatusModel,
            final Collection<RegistryModule> registryModules) {

        final List<RegistryModule> enterpriseModules = registryModules.stream()
                .filter(cf -> cf.getTags().stream()
                        .anyMatch(tag -> tag.equalsIgnoreCase(ModuleTag.IS_ENTERPRISE.value())))
                .toList();;

        // Filter Request for any non-Enterprise modules
        final List<String> nonEnterpriseModules = moduleStatusModel.moduleStatusModel()
                .stream()
                .map(fs -> fs.moduleId().toString().toLowerCase())
                .filter(fid -> enterpriseModules.stream()
                        .map(pf -> pf.getModuleId().toLowerCase())
                        .noneMatch(fid::equals))
                .toList();

        if (!nonEnterpriseModules.isEmpty()) {
            final TenantId tenantId = moduleStatusModel.tenantId();
            throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, nonEnterpriseModules);
        }
    }

Thanks to the Domain data model EnterpriseModuleStatus we built in advance in Step 3, the logic for this step can be significantly simplified, and the structure is very clear.

package com.example.modulesystem.domain.service;

public class EnterpriseModuleGuard {

    public static void guardModuleIsEnterprise(
            final TenantModuleStatuses tenantModuleStatuses,
            final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses) {

        final List<String> enterpriseModules = enterpriseModuleStatuses
                .stream()
                .filter(EnterpriseModuleStatus::isEnterprise)
                .map(module -> module.moduleId().toString())
                .toList();

        final List<String> nonEnterpriseModules = tenantModuleStatuses
                .tenantModuleStatus()
                .stream()
                .map(dfs -> dfs.moduleId().toString().toLowerCase())
                .filter(fid -> enterpriseModuleStatuses.stream()
                        .map(pfs -> pfs.moduleId().toString())
                        .noneMatch(fid::equals)).toList();

        if (!nonEnterpriseModules.isEmpty()) {
            final TenantId tenantId = tenantModuleStatuses.tenantId();
            throw new EnterpriseModuleNotFoundDomainException(tenantId, nonEnterpriseModules);
        }
    }
}

Step 5

After the input data is checked and confirmed to be correct, we only need a simple conversion to prepare it for storage in the system database. Since this is a data conversion internal to the Domain, we place the Mapper directly inside the Domain. We just create a new Mapper and wait for the subsequent Use Case to call it.

package com.example.modulesystem.domain.mapper;

public class EnterpriseModuleToStatusMapper {

    public TenantModuleStatuses toTenantModuleStatuses(
            final TenantModuleStatuses tenantModuleStatuses,
            final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses
    ) {

        final Map<String, String> mappedModuleIds = enterpriseModuleStatuses
                .stream()
                .collect(Collectors.toMap(
                        module -> module.moduleId().toString().toLowerCase(),
                        module -> module.moduleId().toString(),
                        (existingValue, newValue) -> existingValue));

        final List<TenantModuleStatus> correctlyCapitalizedModuleId =
                tenantModuleStatuses.tenantModuleStatus()
                        .stream()
                        .map(dfs -> {
                            final String moduleId = mappedModuleIds.get(dfs.moduleId().toString().toLowerCase());
                            return new TenantModuleStatus(ModuleId.of(moduleId), dfs.isActive());
                        }).toList();

        return new TenantModuleStatuses(tenantModuleStatuses.tenantId(),
                correctlyCapitalizedModuleId);
    }
}

In the Use Case, convert and store.

final boolean statusHasChanged = moduleStatusRepositoryPort.save(enterpriseModuleToStatusMapper.toTenantModuleStatuses(tenantModuleStatuses,enterpriseModuleStatuses));

Step 6

The final step is simple: trigger a method in the external system to invalidate the Cache. Similar to Step 2, we only need to create a Port and an Adapter.

package com.example.modulesystem.application.port.outbound;

public interface EventServicePort {
    void invalidateModuleCacheForTenants(List<String> tenantIds);
}
package com.example.modulesystem.infrastructure.adatper.outbound;

public class EventServiceAdapter implements EventServicePort {

    private final EventService eventService;

    public EventServiceAdapter(final EventService eventService) {
        this.eventService = eventService;
    }

    @Override
    public void invalidateModuleCacheForTenants(final List<String> tenantIds) {
        eventService.invalidateCacheForTenants(tenantIds);
    }
}

Consolidation and Organization

Finally, integrating all steps, we get the final Use Case.

package com.example.modulesystem.application.usecase;

@Slf4j
public class UpdateModuleStatus {

    // outbound port
    private final TenantDirectoryPort tenantDirectoryPort;
    private final RegistryModulePort registryModulePort;
    private final ModuleStatusRepositoryPort moduleStatusRepositoryPort;
    private final EventServicePort eventServicePort;
    // domain service
    private final TenantValidator tenantValidator;
    private final ModuleGuard moduleGuard;
    private final EnterpriseModuleGuard enterpriseModuleGuard;
    private final EnterpriseModuleToStatusMapper enterpriseModuleToStatusMapper;

    public UpdateModuleStatus(final TenantDirectoryPort tenantDirectoryPort,
            final RegistryModulePort registryModulePort,
            final ModuleStatusRepositoryPort moduleStatusRepositoryPort,
            final EventServicePort eventServicePort,
            final TenantValidator tenantValidator,
            final ModuleGuard moduleGuard,
            final EnterpriseModuleGuard enterpriseModuleGuard,
            final EnterpriseModuleToStatusMapper enterpriseModuleToStatusMapper) {
        this.tenantDirectoryPort = tenantDirectoryPort;
        this.registryModulePort = registryModulePort;
        this.moduleStatusRepositoryPort = moduleStatusRepositoryPort;
        this.eventServicePort = eventServicePort;

        this.tenantValidator = tenantValidator;
        this.moduleGuard = moduleGuard;
        this.enterpriseModuleGuard = enterpriseModuleGuard;
        this.enterpriseModuleToStatusMapper = enterpriseModuleToStatusMapper;
    }

    public void handle(final TenantModuleStatuses tenantModuleStatuses) {
        final TenantId tenantId = tenantModuleStatuses.tenantId();

        // Step 2
        final boolean exists = tenantDirectoryPort.tenantExists(tenantId);
        try {
            tenantValidator.guardTenantExists(tenantId, exists);
        } catch (final TenantNotFoundDomainException ex) {
            log.warn("No tenant found for id '{}'", tenantId);
            throw ModuleStatusApiException.unknownTenant(tenantId);
        }

        // Step 3
        final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
        try {
            moduleGuard.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
        } catch (final ModuleNotFoundDomainException ex) {
            throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
        }

        // Step 4.
        try {
            enterpriseModuleGuard.guardModuleIsEnterprise(tenantModuleStatuses, enterpriseModuleStatuses);
        } catch (final ModuleNotFoundDomainException ex) {
            throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, ex.getMissingModuleIds());
        }

        // Step. 5
        final boolean statusHasChanged = moduleStatusRepositoryPort.save(
                enterpriseModuleToStatusMapper.toTenantModuleStatuses(tenantModuleStatuses,enterpriseModuleStatuses));

        if (statusHasChanged) {
            log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
            eventServicePort.invalidateModuleCacheForTenants(List.of(tenantId.toString()));
        }
    }
}

Of course, this isn’t the most concise form; we should simplify it further.

First, consolidate the Catch blocks. Second, we can assume that all Domain Service boundaries are within ModuleStatusService. After this organization, something magical happens—it turns out to be very close to the logic we saw initially! And in appearance, it looks very much like the classic DDD model that relies on a large Service.

The simplified code is as follows:

package com.example.modulesystem.application.usecase;

@Slf4j
public class UpdateModuleStatus {

    // outbound port
    private final TenantDirectoryPort tenantDirectoryPort;
    private final RegistryModulePort registryModulePort;
    private final ModuleStatusRepositoryPort moduleStatusRepositoryPort;
    private final EventServicePort eventServicePort;
    // domain service
    private final ModuleStatusService moduleStatusService;

    public UpdateModuleStatus(final TenantDirectoryPort tenantDirectoryPort,
            final RegistryModulePort registryModulePort,
            final ModuleStatusRepositoryPort moduleStatusRepositoryPort,
            final EventServicePort eventServicePort,
            final ModuleStatusService moduleStatusService
    ) {
        this.tenantDirectoryPort = tenantDirectoryPort;
        this.registryModulePort = registryModulePort;
        this.moduleStatusRepositoryPort = moduleStatusRepositoryPort;
        this.eventServicePort = eventServicePort;
        this.moduleStatusService = moduleStatusService;
    }

    public void handle(final TenantModuleStatuses tenantModuleStatuses) {
        final TenantId tenantId = tenantModuleStatuses.tenantId();
        final boolean exists = tenantDirectoryPort.tenantExists(tenantId);

        try {
            moduleStatusService.guardTenantExists(tenantId, exists);
            final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
            moduleStatusService.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
            moduleStatusService.guardModuleIsEnterprise(tenantModuleStatuses, enterpriseModuleStatuses);
            final boolean statusHasChanged = moduleStatusRepositoryPort.save(
moduleStatusService.toTenantModuleStatuses(tenantModuleStatuses, enterpriseModuleStatuses));
            if (statusHasChanged) {
                log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
                eventServicePort.invalidateModuleCacheForTenants(List.of(tenantId.toString()));
            }
        } catch (final TenantNotFoundDomainException ex) {
            log.warn("No tenant found for id '{}'", tenantId);
            throw ModuleStatusApiException.unknownTenant(tenantId);
        } catch (final ModuleNotFoundDomainException ex) {
            throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
        } catch (final EnterpriseModuleNotFoundDomainException ex) {
            throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, ex.getMissingModuleIds());
        }
    }
}

Finally, I also noticed that the flow of this Use Case is very close to the Acceptance Criteria in the Jira Ticket. Understood from another angle, the Ticket can be an abstraction of the Implementation, with the latter depending on the former.

Invoking Use Cases

In the above Use Cases, we assume there are two ways to call them: one using a Java Interface, and the other using a Resource (i.e., HTTP Request).

The Java Interface is the simplest; you just need to expose the interface of the Use Case class, located in application.port.inbound.

package com.example.modulesystem.application.port.inbound;

public interface ModuleStatusQueryPort {
    TenantModuleStatuses apply(@NonNull final TenantId tenantId);
}

The second way is slightly more complex. HTTP requests belong to a technical implementation, so they can be completely placed in the Inbound Adapter of the Infrastructure. It can directly call the implementation in the Use Case because its responsibility is to handle boundary data conversion and connect internal systems.

The code is as follows:

package com.example.modulesystem.infrastructure.adatper.inbound.rest;

@Slf4j
@Path("/")
@Component
public class EnterpriseModuleStatusResource {

    private final UpdateModuleStatus updateModuleStatus;

    public EnterpriseModuleStatusResource(final UpdateModuleStatus updateModuleStatus) {
        this.updateModuleStatus = updateModuleStatus;
    }

    @PUT
    @Path("tenants/{tenantId}/provisioned")
    @Consumes({MediaType.APPLICATION_JSON})
    public Response updateModuleStatusForTenants(
            @PathParam("tenantId") final TenantId tenantId,
            @Valid final TenantModuleStatusesRequest subscriptionStatusRequest) {
        log.info("Updating module statuses for TenantId: {}", tenantId);

        updateModuleStatus.handle(mapToDomainModel(tenantId, subscriptionStatusRequest.modules()));

        log.debug("Successfully updated module statuses");
        return Response.ok().build();
    }

    private static TenantModuleStatuses mapToDomainModel(final TenantId tenantId,
            final Map<String, Boolean> moduleStatusList) {
        final List<TenantModuleStatus> tenantModuleStatusModels = new ArrayList<>();
        moduleStatusList.forEach((moduleId, isActive) ->
                tenantModuleStatusModels.add(new TenantModuleStatus(ModuleId.of(moduleId),
                        isActive)));
        return new TenantModuleStatuses(tenantId, tenantModuleStatusModels);
    }
}

Here, the Mapper is an optional split; it can be placed in the Resource or a separate Mapper can be created.

It is worth noting that TenantModuleStatusesRequest, as a protocol with the outside world (HTTP), should not be placed in Application or Domain because the technology adopted by the Adapter may change (for example, from HTTP to Kafka), and the Use Case should not be affected in any way.

package com.example.modulesystem.infrastructure.adatper.inbound.rest.dto;

public record TenantModuleStatusesRequest(Map<String, Boolean> modules) {

}

Here is the relevant folder structure:

└── infrastructure
    └── adatper
        ├── inbound
        │   └── rest
        │       ├── EnterpriseModuleStatusResource.java -> resource
        │       └── dto
        │           └── TenantModuleStatusesRequest.java -> DTO
        └── outbound
            ├── mapper
            │   └── RegistryModuleToEnterpriseModuleStatusMapper.java
            ├── RegistryModuleAdapter.java
            ├── EventServiceAdapter.java
            └── TenantDirectoryAdapter.java

Conclusion and Coda

Let’s review the map we built again:

.
├── application
│   ├── exception
│   │   └── ModuleStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── ModuleStatusQueryPort.java
│   │   └── outbound
│   │       ├── ModuleStatusRepositoryPort.java
│   │       ├── RegistryModulePort.java
│   │       ├── EventServicePort.java
│   │       └── TenantDirectoryPort.java
│   └── usecase
│       ├── ModuleStatusQueryUseCase.java
│       ├── ResetModuleStatusUseCase.java
│       └── UpdateModuleStatus.java
├── domain
│   ├── exception
│   │   ├── EnterpriseModuleNotFoundDomainException.java
│   │   ├── ModuleNotFoundDomainException.java
│   │   └── TenantNotFoundDomainException.java
│   ├── model
│   │   ├── EnterpriseModuleStatus.java
│   │   ├── TenantModuleStatuses.java
│   │   └── TenantModuleStatus.java
│   └── service
│       └── ModuleStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── EnterpriseModuleStatusResource.java
        │       └── dto
        │           └── TenantModuleStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── RegistryModuleToEnterpriseModuleStatusMapper.java
            ├── RegistryModuleAdapter.java
            ├── EventServiceAdapter.java
            └── TenantDirectoryAdapter.java

We started from the core Domain data structure design, and in conjunction with the Repository Port, designed two simple Use Cases. Then, we delved into how to handle the most complex Case, including introducing external data, data conversion at the boundary and inside the Domain, how to handle Exceptions, and how to trigger Use Cases in different ways.

Of course, this is just a review of this simple example, and the situations encountered in actual development are often much more complicated. What I want to say to you, and equally to myself, is: This is definitely not the perfect solution, nor is it a universal architecture suitable for all situations. The complicated data type conversions and over-engineering problems remain unsolved and have even intensified, as can be quickly understood by looking at the examples above.

Currently, I interpret Hexagonal/Clean Architecture as a refinement of classic DDD. It retains the core of a pure Domain, the difference being that it delegates part of the functions from the large, all-encompassing Service to the Use Case. (Personally, I feel this trend is somewhat like a shift from being centered on functional technical development to being centered on quickly adapting to different user needs).

Using the characteristics of the Interface, we achieved Dependency Inversion, and the dependency path became Infrastructure -> Application -> Domain. Before, I only focused on the fact that an Interface could be Implemented, without focusing on its characteristic of being able to define variable types. It is precisely because of this difference in focus that control has quietly shifted.

As for the specific project, it will continue to be very large and ancient, and it is almost difficult to change the structure of the original code. Old-style Services will persist for a long time, and the new structure will be slow to appear, but its existence will help me establish a clear architectural awareness in my mind and help me organize and categorize legacy logic.

Disclaimer: The code examples provided are simplified and generalized for educational purposes, focusing on architectural patterns rather than specific project implementation.

Thank you for reading! Your support is appreciated.

If you enjoyed this, consider buying me a coffee. ☕️