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 FeatureStatus, 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
│   │   └── FeatureStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── FeatureStatusQueryPort.java
│   │   └── outbound
│   │       ├── FeatureStatusRepositoryPort.java
│   │       ├── CatalogFeaturePort.java
│   │       ├── CacheInvalidationPort.java
│   │       └── AccountDirectoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       ├── ResetFeatureStatusUseCase.java
│       └── UpdateFeatureStatus.java
├── domain
│   ├── exception
│   │   ├── EligibleFeatureNotFoundDomainException.java
│   │   ├── FeatureNotFoundDomainException.java
│   │   └── AccountNotFoundDomainException.java
│   ├── model
│   │   ├── EligibleFeatureStatus.java
│   │   ├── AccountFeatureStatuses.java
│   │   └── AccountFeatureStatus.java
│   └── service
│       └── FeatureStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── EligibleFeatureStatusResource.java
        │       └── dto
        │           └── AccountFeatureStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToEligibleFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── CacheInvalidationAdapter.java
            └── AccountDirectoryAdapter.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 Feature ID and its status form the most basic Status, while Statuses contain a list of Statuses and an account identifier (AccountId).

package com.example.featuresystem.domain.model;

public record AccountFeatureStatus(@NonNull FeatureId featureId, Boolean isActive) {
    public AccountFeatureStatus withStatusReset(){
        return new AccountFeatureStatus(this.featureId(), false);
    }
}
package com.example.featuresystem.domain.model;

public record AccountFeatureStatuses(@NonNull AccountId accountId, @NonNull List<AccountFeatureStatus> accountFeatureStatus) {

    public Optional<AccountFeatureStatus> getFeatureStatusFor(
            @NonNull final FeatureId featureId) {
        return accountFeatureStatus.stream()
                .filter(fs -> fs.featureId().toString().equalsIgnoreCase(featureId.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.featuresystem.application.port.outbound;

public interface FeatureStatusRepositoryPort {
    AccountFeatureStatuses findByAccountId(@NonNull final AccountId accountId);

    boolean save(@NonNull final AccountFeatureStatuses accountFeatureStatuses);
}

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.featuresystem.application.usecase;

public class FeatureStatusQueryUseCase {

    private final FeatureStatusRepositoryPort featureStatusRepository;

    public FeatureStatusQueryUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
        this.featureStatusRepository = featureStatusRepository;
    }

    public AccountFeatureStatuses apply(@NonNull final AccountId accountId) {
        return featureStatusRepository.findByAccountId(accountId);
    }
}

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.featuresystem.application.usecase;

@Slf4j
public class ResetFeatureStatusUseCase {
    private final FeatureStatusRepositoryPort featureStatusRepository;

    public ResetFeatureStatusUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
        this.featureStatusRepository = featureStatusRepository;
    }

    public void apply(@NonNull final AccountId accountId) {
        log.info("Invoking factory reset of feature statuses for AccountId={}", accountId);
        final AccountFeatureStatuses currentStatuses = featureStatusRepository.findByAccountId(accountId);
        final AccountFeatureStatuses resetStatuses = new AccountFeatureStatuses(
                currentStatuses.accountId(),
                currentStatuses.accountFeatureStatus()
                        .stream()
                        .map(AccountFeatureStatus::withStatusReset)
                        .toList()
        );
        featureStatusRepository.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
│   │       └── FeatureStatusRepositoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       └── ResetFeatureStatusUseCase.java
└── domain
    └── model
        ├── AccountFeatureStatus.java
        └── AccountFeatureStatuses.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 AccountId from the input parameters.
  2. Ensure the AccountId exists via the external AccountDirectory service.
  3. Get the CatalogFeature list from the external CatalogLookup and CatalogFeatureService, and determine if the requested features exist in this list.
  4. Determine if the features belong to the eligible type.
  5. Transform the data type and store it.
  6. Call the external CacheInvalidationService to trigger cache invalidation.

The original core code briefly describes this process:

public void apply(final FeatureStatusModel featureStatusModel) {
        // Step 1
        final AccountId accountId = featureStatusModel.accountId();
        // Step 2
        guardAccountExists(accountId);
        // Step 3
        final List<CatalogFeature> catalogFeatures = lookupFeaturesForCatalog(accountId, getContext());
        guardFeatureExists(featureStatusModel, catalogFeatures);
        // Step 4
        guardFeatureIsEligible(featureStatusModel, catalogFeatures);
        // Step 5
        final FeatureStatusModel mappedFeatureStatusModel = mapFeatureIdToMatchCatalog(featureStatusModel, catalogFeatures);
        final boolean stateHasChanged = featureStatusRepository.save(mappedFeatureStatusModel);
        // Step 6
        if (stateHasChanged) {
            log.debug("Invalidating FeatureCache for AccountId={}", accountId);
            cacheInvalidationService.invalidateCacheForAccounts(List.of(accountId.toString()));
        }
    }

Deep Dive and Breakdown of the Use Case

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

final AccountId accountId = featureStatusModel.accountId();

Step 2

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

private void guardAccountExists(final AccountId accountId) {
    final boolean accountExists = accountDirectory.accountExists(accountId);

    if (!accountExists) {
        log.warn("No account found for id '{}'", accountId);
        throw FeatureStatusApiException.unknownAccount(accountId);
    }
}

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

  • Infrastructure: Specific technical control, e.g., ReadOnlyRoutingContext.open.
  • Outbound Port: Calling external dependencies, e.g., accountDirectory.accountExists.
  • Domain: Business rules, i.e., the account must exist if(!accountExists).

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.featuresystem.domain.service;

public class AccountValidator {
    public void guardAccountExists(final AccountId accountId, final boolean exists) {
        if (!exists){
            throw new AccountNotFoundDomainException(accountId);
        }
    }
}

And the Exception needed here:

package com.example.featuresystem.domain.exception;

public class AccountNotFoundDomainException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final AccountId accountId;
    public AccountNotFoundDomainException(final AccountId accountId) {
        super("No account found for AccountId: "+accountId.toString());
        this.accountId = accountId;
    }

    public AccountId getAccountId() {
        return accountId;
    }
}

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 AccountDirectory. 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.featuresystem.application.port.outbound;

public interface AccountDirectoryPort {
    boolean accountExists(AccountId accountId);
}

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.featuresystem.infrastructure.adatper.outbound;

public class AccountDirectoryAdapter implements AccountDirectoryPort {

    private final AccountDirectory accountDirectory;

    public AccountDirectoryAdapter(final AccountDirectory accountDirectory) {
        this.accountDirectory = accountDirectory;
    }

    @Override
    public boolean accountExists(final AccountId accountId) {
        // Simulating technical implementation details
        try (var rc = ReadOnlyRoutingContext.open(
                RoutingMode.READ_ONLY, getClass().getName())) {
            return accountDirectory.accountExists(accountId);
        }
    }
}

Looking back at the previous code, we find there is also a logging logic: log.warn("No account found for id '{}'", accountId);. 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 account existence for ID {}: {}", accountId, 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 = accountDirectoryPort.accountExists(accountId);
try{
 accountValidator.guardAccountExists(accountId, exists);
} catch (final AccountNotFoundDomainException ex) {
 log.warn("No account found for id '{}'", accountId);
 throw FeatureStatusApiException.unknownAccount(accountId);
}

Step 3

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

final List<CatalogFeature> catalogFeatures = lookupFeaturesForCatalog(accountId, getContext());
guardFeatureExists(featureStatusModel, catalogFeatures);

Note that we have introduced the external model CatalogFeature. 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.featuresystem.domain.model;

public record EligibleFeatureStatus(@NonNull FeatureId featureId, @NonNull boolean isEligible) {
}

Then we need a Mapper that can convert the external CatalogFeature 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.featuresystem.infrastructure.adatper.outbound.mapper;

public class CatalogFeatureToEligibleFeatureStatusMapper {

    public static List<EligibleFeatureStatus> toDomain(final List<CatalogFeature> catalogFeatures) {
        return catalogFeatures
                .stream()
                .map(feature -> new EligibleFeatureStatus(
                        FeatureId.of(feature.getFeatureId()),
                        hasEligibleTag(feature)
                )).toList();
    }

    private static boolean hasEligibleTag(final CatalogFeature feature) {
        return feature.getTags().stream().anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_ELIGIBLE.value()));
    }
}

With the internal data type and converter, we can create a Port in the Application’s Outbound to get EligibleFeatureStatus (or CatalogFeature), 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 components 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.featuresystem.application.port.outbound;

public interface CatalogFeaturePort {
    List<EligibleFeatureStatus> findByAccountId(AccountId accountId);
}

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 CatalogFeature into EligibleFeatureStatus.

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

@Slf4j
public class CatalogFeatureAdapter implements CatalogFeaturePort {

    private final CacheInvalidationService cacheInvalidationService;
    private final CatalogLookup catalogLookup;
    private final CatalogFeatureService catalogFeatureService;

    public CatalogFeatureAdapter(
            final CacheInvalidationService cacheInvalidationService,
            final CatalogLookup catalogLookup,
            final CatalogFeatureService catalogFeatureService){
        this.cacheInvalidationService = cacheInvalidationService;
        this.catalogLookup = catalogLookup;
        this.catalogFeatureService = catalogFeatureService;
    }

    // Omitting specific context building methods...

    @Override
    public List<EligibleFeatureStatus> findByAccountId(final AccountId accountId) {
        // Simulating context
        final var context = new Object();

        log.debug("Fetching catalog features for AccountId={}", accountId);
        final List<CatalogInfo> catalogs = catalogLookup.getCatalogsFor(accountId, null, context);
        if (catalogs.isEmpty()) {
            return Collections.emptyList();
        }

        final List<CatalogFeature> associatedFeatures = new ArrayList<>();

        for (final CatalogInfo catalog : catalogs) {
            final Optional<CatalogFeatures> featuresFromCache = Optional.ofNullable(
            catalogFeatureService.findByCatalogIdCached(catalog.getCatalogId()));
            final CatalogFeatures features = featuresFromCache.orElseGet(
                    () -> catalogFeatureService.findByCatalogId(catalog.getCatalogId()));
            if (features != null) {
                associatedFeatures.addAll(features.getAssociatedFeatures());
            } else {
                log.debug("No features for catalog '{}' found", catalog.getCatalogId());
            }
        }

        return CatalogFeatureToEligibleFeatureStatusMapper.toDomain(associatedFeatures);
    }
}

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 features match the existing features. If a feature does not exist at all, an error is thrown directly.

package com.example.featuresystem.domain.service;

public class FeatureGuard {
    public void guardFeatureExists(
            final AccountFeatureStatuses accountFeatureStatuses,
            final Collection<EligibleFeatureStatus> eligibleFeatureStatuses) {

        final List<String> featureIds = eligibleFeatureStatuses.stream()
                .map(featureIsEligible -> featureIsEligible.featureId().toString())
                .toList();

        final List<String> missingIds = accountFeatureStatuses.accountFeatureStatus().stream().map(
                dfs -> dfs.featureId().toString().toLowerCase()
        ).filter(dfs -> !featureIds.contains(dfs)).toList();

        if(featureIds.isEmpty() || !missingIds.isEmpty()) {
            throw new FeatureNotFoundDomainException(accountFeatureStatuses.accountId(), missingIds);
        }
    }
}

Finally, the Use Case utilizes this logic:

final List<EligibleFeatureStatus> eligibleFeatureStatuses = catalogFeaturePort.findByAccountId(accountId);
try {
 featureGuard.guardFeatureExists(accountFeatureStatuses, eligibleFeatureStatuses);
} catch (final FeatureNotFoundDomainException ex) {
 throw FeatureStatusApiException.noFeatureForCatalogFound(accountId, ex.getMissingFeatureIds().toString());
}

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

  • FeatureNotFoundDomainException is located in domain.exception.
  • FeatureStatusApiException 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 feature category belongs to “eligible”. We can see that the original code relied heavily on external data structures:

private static void guardFeatureIsEligible(
            final AccountFeatureStatusesModel featureStatusModel,
            final Collection<CatalogFeature> catalogFeatures) {

        final List<CatalogFeature> eligibleFeatures = catalogFeatures.stream()
                .filter(cf -> cf.getTags().stream()
                        .anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_ELIGIBLE.value())))
                .toList();;

        // Filter request for any non-eligible features
        final List<String> nonEligibleFeatures = featureStatusModel.featureStatusModel()
                .stream()
                .map(fs -> fs.featureId().toString().toLowerCase())
                .filter(fid -> eligibleFeatures.stream()
                        .map(pf -> pf.getFeatureId().toLowerCase())
                        .noneMatch(fid::equals))
                .toList();

        if (!nonEligibleFeatures.isEmpty()) {
            final AccountId accountId = featureStatusModel.accountId();
            throw FeatureStatusApiException.nonEligibleFeatureProvided(accountId, nonEligibleFeatures);
        }
    }

Thanks to the Domain data model EligibleFeatureStatus 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.featuresystem.domain.service;

public class EligibleFeatureGuard {

    public static void guardFeatureIsEligible(
            final AccountFeatureStatuses accountFeatureStatuses,
            final Collection<EligibleFeatureStatus> eligibleFeatureStatuses) {

        final List<String> eligibleFeatures = eligibleFeatureStatuses
                .stream()
                .filter(EligibleFeatureStatus::isEligible)
                .map(feature -> feature.featureId().toString())
                .toList();

        final List<String> nonEligibleFeatures = accountFeatureStatuses
                .accountFeatureStatus()
                .stream()
                .map(dfs -> dfs.featureId().toString().toLowerCase())
                .filter(fid -> eligibleFeatureStatuses.stream()
                        .map(pfs -> pfs.featureId().toString())
                        .noneMatch(fid::equals)).toList();

        if (!nonEligibleFeatures.isEmpty()) {
            final AccountId accountId = accountFeatureStatuses.accountId();
            throw new EligibleFeatureNotFoundDomainException(accountId, nonEligibleFeatures);
        }
    }
}

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.featuresystem.domain.mapper;

public class EligibleFeatureToStatusMapper {

    public AccountFeatureStatuses toAccountFeatureStatuses(
            final AccountFeatureStatuses accountFeatureStatuses,
            final Collection<EligibleFeatureStatus> eligibleFeatureStatuses
    ) {

        final Map<String, String> mappedFeatureIds = eligibleFeatureStatuses
                .stream()
                .collect(Collectors.toMap(
                        feature -> feature.featureId().toString().toLowerCase(),
                        feature -> feature.featureId().toString(),
                        (existingValue, newValue) -> existingValue));

        final List<AccountFeatureStatus> correctlyCapitalizedFeatureId =
                accountFeatureStatuses.accountFeatureStatus()
                        .stream()
                        .map(dfs -> {
                            final String featureId = mappedFeatureIds.get(dfs.featureId().toString().toLowerCase());
                            return new AccountFeatureStatus(FeatureId.of(featureId), dfs.isActive());
                        }).toList();

        return new AccountFeatureStatuses(accountFeatureStatuses.accountId(),
                correctlyCapitalizedFeatureId);
    }
}

In the Use Case, convert and store.

final boolean statusHasChanged = featureStatusRepositoryPort.save(eligibleFeatureToStatusMapper.toAccountFeatureStatuses(accountFeatureStatuses,eligibleFeatureStatuses));

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.featuresystem.application.port.outbound;

public interface CacheInvalidationPort {
    void invalidateFeatureCacheForAccounts(List<String> accountIds);
}
package com.example.featuresystem.infrastructure.adatper.outbound;

public class CacheInvalidationAdapter implements CacheInvalidationPort {

    private final CacheInvalidationService cacheInvalidationService;

    public CacheInvalidationAdapter(final CacheInvalidationService cacheInvalidationService) {
        this.cacheInvalidationService = cacheInvalidationService;
    }

    @Override
    public void invalidateFeatureCacheForAccounts(final List<String> accountIds) {
        cacheInvalidationService.invalidateCacheForAccounts(accountIds);
    }
}

Consolidation and Organization

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

package com.example.featuresystem.application.usecase;

@Slf4j
public class UpdateFeatureStatus {

    // outbound port
    private final AccountDirectoryPort accountDirectoryPort;
    private final CatalogFeaturePort catalogFeaturePort;
    private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
    private final CacheInvalidationPort cacheInvalidationPort;
    // domain service
    private final AccountValidator accountValidator;
    private final FeatureGuard featureGuard;
    private final EligibleFeatureGuard eligibleFeatureGuard;
    private final EligibleFeatureToStatusMapper eligibleFeatureToStatusMapper;

    public UpdateFeatureStatus(final AccountDirectoryPort accountDirectoryPort,
            final CatalogFeaturePort catalogFeaturePort,
            final FeatureStatusRepositoryPort featureStatusRepositoryPort,
            final CacheInvalidationPort cacheInvalidationPort,
            final AccountValidator accountValidator,
            final FeatureGuard featureGuard,
            final EligibleFeatureGuard eligibleFeatureGuard,
            final EligibleFeatureToStatusMapper eligibleFeatureToStatusMapper) {
        this.accountDirectoryPort = accountDirectoryPort;
        this.catalogFeaturePort = catalogFeaturePort;
        this.featureStatusRepositoryPort = featureStatusRepositoryPort;
        this.cacheInvalidationPort = cacheInvalidationPort;

        this.accountValidator = accountValidator;
        this.featureGuard = featureGuard;
        this.eligibleFeatureGuard = eligibleFeatureGuard;
        this.eligibleFeatureToStatusMapper = eligibleFeatureToStatusMapper;
    }

    public void handle(final AccountFeatureStatuses accountFeatureStatuses) {
        final AccountId accountId = accountFeatureStatuses.accountId();

        // Step 2
        final boolean exists = accountDirectoryPort.accountExists(accountId);
        try {
            accountValidator.guardAccountExists(accountId, exists);
        } catch (final AccountNotFoundDomainException ex) {
            log.warn("No account found for id '{}'", accountId);
            throw FeatureStatusApiException.unknownAccount(accountId);
        }

        // Step 3
        final List<EligibleFeatureStatus> eligibleFeatureStatuses = catalogFeaturePort.findByAccountId(accountId);
        try {
            featureGuard.guardFeatureExists(accountFeatureStatuses, eligibleFeatureStatuses);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.noFeatureForCatalogFound(accountId, ex.getMissingFeatureIds().toString());
        }

        // Step 4.
        try {
            eligibleFeatureGuard.guardFeatureIsEligible(accountFeatureStatuses, eligibleFeatureStatuses);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.nonEligibleFeatureProvided(accountId, ex.getMissingFeatureIds());
        }

        // Step. 5
        final boolean statusHasChanged = featureStatusRepositoryPort.save(
                eligibleFeatureToStatusMapper.toAccountFeatureStatuses(accountFeatureStatuses,eligibleFeatureStatuses));

        if (statusHasChanged) {
            log.debug("Invalidating FeatureCache for AccountId={}", accountId);
            cacheInvalidationPort.invalidateFeatureCacheForAccounts(List.of(accountId.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 FeatureStatusService. 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.featuresystem.application.usecase;

@Slf4j
public class UpdateFeatureStatus {

    // outbound port
    private final AccountDirectoryPort accountDirectoryPort;
    private final CatalogFeaturePort catalogFeaturePort;
    private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
    private final CacheInvalidationPort cacheInvalidationPort;
    // domain service
    private final FeatureStatusService featureStatusService;

    public UpdateFeatureStatus(final AccountDirectoryPort accountDirectoryPort,
            final CatalogFeaturePort catalogFeaturePort,
            final FeatureStatusRepositoryPort featureStatusRepositoryPort,
            final CacheInvalidationPort cacheInvalidationPort,
            final FeatureStatusService featureStatusService
    ) {
        this.accountDirectoryPort = accountDirectoryPort;
        this.catalogFeaturePort = catalogFeaturePort;
        this.featureStatusRepositoryPort = featureStatusRepositoryPort;
        this.cacheInvalidationPort = cacheInvalidationPort;
        this.featureStatusService = featureStatusService;
    }

    public void handle(final AccountFeatureStatuses accountFeatureStatuses) {
        final AccountId accountId = accountFeatureStatuses.accountId();
        final boolean exists = accountDirectoryPort.accountExists(accountId);

        try {
            featureStatusService.guardAccountExists(accountId, exists);
            final List<EligibleFeatureStatus> eligibleFeatureStatuses = catalogFeaturePort.findByAccountId(accountId);
            featureStatusService.guardFeatureExists(accountFeatureStatuses, eligibleFeatureStatuses);
            featureStatusService.guardFeatureIsEligible(accountFeatureStatuses, eligibleFeatureStatuses);
            final boolean statusHasChanged = featureStatusRepositoryPort.save(
featureStatusService.toAccountFeatureStatuses(accountFeatureStatuses, eligibleFeatureStatuses));
            if (statusHasChanged) {
                log.debug("Invalidating FeatureCache for AccountId={}", accountId);
                cacheInvalidationPort.invalidateFeatureCacheForAccounts(List.of(accountId.toString()));
            }
        } catch (final AccountNotFoundDomainException ex) {
            log.warn("No account found for id '{}'", accountId);
            throw FeatureStatusApiException.unknownAccount(accountId);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.noFeatureForCatalogFound(accountId, ex.getMissingFeatureIds().toString());
        } catch (final EligibleFeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.nonEligibleFeatureProvided(accountId, ex.getMissingFeatureIds());
        }
    }
}

Finally, I also noticed that the flow of this Use Case is very close to the Acceptance Criteria in the related work item. Understood from another angle, the work item 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.featuresystem.application.port.inbound;

public interface FeatureStatusQueryPort {
    AccountFeatureStatuses apply(@NonNull final AccountId accountId);
}

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.featuresystem.infrastructure.adatper.inbound.rest;

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

    private final UpdateFeatureStatus updateFeatureStatus;

    public EligibleFeatureStatusResource(final UpdateFeatureStatus updateFeatureStatus) {
        this.updateFeatureStatus = updateFeatureStatus;
    }

    @PUT
    @Path("accounts/{accountId}/statuses")
    @Consumes({MediaType.APPLICATION_JSON})
    public Response updateFeatureStatusForAccounts(
            @PathParam("accountId") final AccountId accountId,
            @Valid final AccountFeatureStatusesRequest featureStatusRequest) {
        log.info("Updating feature statuses for AccountId: {}", accountId);

        updateFeatureStatus.handle(mapToDomainModel(accountId, featureStatusRequest.features()));

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

    private static AccountFeatureStatuses mapToDomainModel(final AccountId accountId,
            final Map<String, Boolean> featureStatusList) {
        final List<AccountFeatureStatus> accountFeatureStatusModels = new ArrayList<>();
        featureStatusList.forEach((featureId, isActive) ->
                accountFeatureStatusModels.add(new AccountFeatureStatus(FeatureId.of(featureId),
                        isActive)));
        return new AccountFeatureStatuses(accountId, accountFeatureStatusModels);
    }
}

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 AccountFeatureStatusesRequest, 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 a message-driven adapter), and the Use Case should not be affected in any way.

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

public record AccountFeatureStatusesRequest(Map<String, Boolean> features) {

}

Here is the relevant folder structure:

└── infrastructure
    └── adatper
        ├── inbound
        │   └── rest
        │       ├── EligibleFeatureStatusResource.java -> resource
        │       └── dto
        │           └── AccountFeatureStatusesRequest.java -> DTO
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToEligibleFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── CacheInvalidationAdapter.java
            └── AccountDirectoryAdapter.java

Conclusion and Coda

Let’s review the map we built again:

.
├── application
│   ├── exception
│   │   └── FeatureStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── FeatureStatusQueryPort.java
│   │   └── outbound
│   │       ├── FeatureStatusRepositoryPort.java
│   │       ├── CatalogFeaturePort.java
│   │       ├── CacheInvalidationPort.java
│   │       └── AccountDirectoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       ├── ResetFeatureStatusUseCase.java
│       └── UpdateFeatureStatus.java
├── domain
│   ├── exception
│   │   ├── EligibleFeatureNotFoundDomainException.java
│   │   ├── FeatureNotFoundDomainException.java
│   │   └── AccountNotFoundDomainException.java
│   ├── model
│   │   ├── EligibleFeatureStatus.java
│   │   ├── AccountFeatureStatuses.java
│   │   └── AccountFeatureStatus.java
│   └── service
│       └── FeatureStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── EligibleFeatureStatusResource.java
        │       └── dto
        │           └── AccountFeatureStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToEligibleFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── CacheInvalidationAdapter.java
            └── AccountDirectoryAdapter.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. ☕️