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
│ │ ├── NotificationServicePort.java
│ │ └── DeviceInventoryPort.java
│ └── usecase
│ ├── FeatureStatusQueryUseCase.java
│ ├── ResetFeatureStatusUseCase.java
│ └── UpdateFeatureStatus.java
├── domain
│ ├── exception
│ │ ├── PremiumFeatureNotFoundDomainException.java
│ │ ├── FeatureNotFoundDomainException.java
│ │ └── DeviceNotFoundDomainException.java
│ ├── model
│ │ ├── PremiumFeatureStatus.java
│ │ ├── DeviceFeatureStatuses.java
│ │ └── DeviceFeatureStatus.java
│ └── service
│ └── FeatureStatusService.java
└── infrastructure
└── adapter
├── inbound
│ └── rest
│ ├── PremiumFeatureStatusResource.java
│ └── dto
│ └── DeviceFeatureStatusesRequest.java
└── outbound
├── mapper
│ └── CatalogFeatureToPremiumFeatureStatusMapper.java
├── CatalogFeatureAdapter.java
├── NotificationServiceAdapter.java
└── DeviceInventoryAdapter.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 a device identifier (DeviceId).
package com.example.featuresystem.domain.model;
import com.example.foundation.core.FeatureId;
import lombok.NonNull;
public record DeviceFeatureStatus(@NonNull FeatureId featureId, Boolean isActive) {
public DeviceFeatureStatus withStatusReset(){
return new DeviceFeatureStatus(this.featureId(), false);
}
}
package com.example.featuresystem.domain.model;
import java.util.List;
import java.util.Optional;
import com.example.foundation.core.FeatureId;
import com.example.foundation.core.DeviceId;
import lombok.NonNull;
public record DeviceFeatureStatuses(@NonNull DeviceId deviceId, @NonNull List<DeviceFeatureStatus> deviceFeatureStatus) {
public Optional<DeviceFeatureStatus> getFeatureStatusFor(
@NonNull final FeatureId featureId) {
return deviceFeatureStatus.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;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;
public interface FeatureStatusRepositoryPort {
DeviceFeatureStatuses findByDeviceId(@NonNull final DeviceId deviceId);
boolean save(@NonNull final DeviceFeatureStatuses deviceFeatureStatuses);
}
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;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;
public class FeatureStatusQueryUseCase {
private final FeatureStatusRepositoryPort featureStatusRepository;
public FeatureStatusQueryUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
this.featureStatusRepository = featureStatusRepository;
}
public DeviceFeatureStatuses apply(@NonNull final DeviceId deviceId) {
return featureStatusRepository.findByDeviceId(deviceId);
}
}
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;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ResetFeatureStatusUseCase {
private final FeatureStatusRepositoryPort featureStatusRepository;
public ResetFeatureStatusUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
this.featureStatusRepository = featureStatusRepository;
}
public void apply(@NonNull final DeviceId deviceId) {
log.info("Invoking factory reset of feature statuses for DeviceId={}", deviceId);
final DeviceFeatureStatuses currentStatuses = featureStatusRepository.findByDeviceId(deviceId);
final DeviceFeatureStatuses resetStatuses = new DeviceFeatureStatuses(
currentStatuses.deviceId(),
currentStatuses.deviceFeatureStatus()
.stream()
.map(DeviceFeatureStatus::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
├── DeviceFeatureStatus.java
└── DeviceFeatureStatuses.java
The third Use Case is the most complex, but also the closest to reality. We need to complete the following steps in order:
- Get the DeviceId from the input parameters.
- Ensure the DeviceId exists via the external DeviceInventory service.
- Get the
CatalogFeaturelist from the externalCatalogLookupandCatalogFeatureService, and determine if the Features exist in this list. - Determine if the Features belong to the Premium type.
- Transform the data type and store it.
- Call the external SubscriptionService to trigger cache invalidation.
The original core code briefly describes this process:
public void apply(final FeatureStatusModel featureStatusModel) {
// Step 1
final DeviceId deviceId = featureStatusModel.deviceId();
// Step 2
guardDeviceExists(deviceId);
// Step 3
final List<CatalogFeature> catalogFeatures = lookupFeaturesForCatalog(deviceId, getContext());
guardFeatureExists(featureStatusModel, catalogFeatures);
// Step 4
guardFeatureIsPremium(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 DeviceId={}", deviceId);
notificationService.invalidateCacheForDevices(List.of(deviceId.toString()));
}
}
Deep Dive and Breakdown of the Use Case
Step 1 is to get the DeviceId from the input parameters. It is very simple, so we can skip directly to Step 2. The DeviceId type defaults to a core type defined by the system, so I won’t elaborate on it here.
final DeviceId deviceId = featureStatusModel.deviceId();
Step 2
Step 2 requires checking whether the DeviceId exists via the external system DeviceInventory. The original method is as follows:
private void guardDeviceExists(final DeviceId deviceId) {
// Simulating proprietary driver connection control
try (final var rc = InternalDriver.controlRouting(RoutingControl.READ_ONLY, getClass().getName())) {
final boolean deviceExists = deviceInventory.deviceExists(deviceId);
if (!deviceExists) {
log.warn("No device found for id '{}'", deviceId);
throw FeatureStatusApiException.unknownDevice(deviceId);
}
}
}
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.,
deviceInventory.deviceExists. - Domain: Business rules, i.e., the device must exist
if(!deviceExists).
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;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;
public class DeviceValidator {
public void guardDeviceExists(final DeviceId deviceId, final boolean exists) {
if (!exists){
throw new DeviceNotFoundDomainException(deviceId);
}
}
}
And the Exception needed here:
package com.example.featuresystem.domain.exception;
import com.example.foundation.core.DeviceId;
public class DeviceNotFoundDomainException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final DeviceId deviceId;
public DeviceNotFoundDomainException(final DeviceId deviceId) {
super("No device found for DeviceId: "+deviceId.toString());
this.deviceId = deviceId;
}
public DeviceId getDeviceId() {
return deviceId;
}
}
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 DeviceInventory. 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;
import com.example.foundation.core.DeviceId;
public interface DeviceInventoryPort {
boolean deviceExists(DeviceId deviceId);
}
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;
import com.example.foundation.core.DeviceId;
import com.example.foundation.drivers.InternalDriver; // Simulating generic driver
import com.example.external.inventory.DeviceInventory;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;
public class DeviceInventoryAdapter implements DeviceInventoryPort {
private final DeviceInventory deviceInventory;
public DeviceInventoryAdapter(final DeviceInventory deviceInventory) {
this.deviceInventory = deviceInventory;
}
@Override
public boolean deviceExists(final DeviceId deviceId) {
// Simulating technical implementation details
try (var rc = InternalDriver.controlRouting(
InternalDriver.READ_ONLY, getClass().getName())) {
return deviceInventory.deviceExists(deviceId);
}
}
}
Looking back at the previous code, we find there is also a logging logic: log.warn("No device found for id '{}'", deviceId);. 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 device existence for ID {}: {}", deviceId, 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 = deviceInventoryPort.deviceExists(deviceId);
try{
deviceValidator.guardDeviceExists(deviceId, exists);
} catch (final DeviceNotFoundDomainException ex) {
log.warn("No device found for id '{}'", deviceId);
throw FeatureStatusApiException.unknownDevice(deviceId);
}
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(deviceId, 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;
import com.example.foundation.core.FeatureId;
import lombok.NonNull;
public record PremiumFeatureStatus(@NonNull FeatureId featureId, @NonNull boolean isPremium) {
}
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;
import java.util.List;
import com.example.foundation.core.FeatureId;
import com.example.featuresystem.domain.constants.FeatureTag;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.external.catalog.CatalogFeature;
public class CatalogFeatureToPremiumFeatureStatusMapper {
public static List<PremiumFeatureStatus> toDomain(final List<CatalogFeature> catalogFeatures) {
return catalogFeatures
.stream()
.map(feature -> new PremiumFeatureStatus(
FeatureId.of(feature.getFeatureId()),
hasPremiumTag(feature)
)).toList();
}
private static boolean hasPremiumTag(final CatalogFeature feature) {
return feature.getTags().stream().anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_PREMIUM.value()));
}
}
With the internal data type and converter, we can create a Port in the Application’s Outbound to get PremiumFeatureStatus (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 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.featuresystem.application.port.outbound;
import java.util.List;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
public interface CatalogFeaturePort {
List<PremiumFeatureStatus> findByDeviceId(DeviceId deviceId);
}
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 PremiumFeatureStatus.
package com.example.featuresystem.infrastructure.adatper.outbound;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import com.example.foundation.core.DeviceId;
import com.example.external.catalog.CatalogLookup;
import com.example.external.catalog.model.CatalogInfo;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.infrastructure.adatper.outbound.mapper.CatalogFeatureToPremiumFeatureStatusMapper;
import com.example.external.catalog.CatalogFeature;
import com.example.external.catalog.CatalogFeatures;
import com.example.featuresystem.services.CatalogFeatureService;
import com.example.featuresystem.services.NotificationService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CatalogFeatureAdapter implements CatalogFeaturePort {
private final NotificationService notificationService;
private final CatalogLookup catalogLookup;
private final CatalogFeatureService catalogFeatureService;
public CatalogFeatureAdapter(
final NotificationService notificationService,
final CatalogLookup catalogLookup,
final CatalogFeatureService catalogFeatureService){
this.notificationService = notificationService;
this.catalogLookup = catalogLookup;
this.catalogFeatureService = catalogFeatureService;
}
// Omitting specific context building methods...
@Override
public List<PremiumFeatureStatus> findByDeviceId(final DeviceId deviceId) {
// Simulating context
final var context = new Object();
log.debug("Fetching CatalogFeatures for DeviceId={}", deviceId);
final List<CatalogInfo> catalogs = catalogLookup.getCatalogsFor(deviceId, 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.findUnfilteredByCatalogIdCached(catalog.getCatalogId()));
final CatalogFeatures features = featuresFromCache.orElseGet(
() -> catalogFeatureService.findUnfilteredByCatalogId(catalog.getCatalogId()));
if (features != null) {
associatedFeatures.addAll(features.getAssociatedFeatures());
} else {
log.debug("No Features for catalog '{}' found", catalog.getCatalogId());
}
}
return CatalogFeatureToPremiumFeatureStatusMapper.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;
import java.util.Collection;
import java.util.List;
import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
public class FeatureGuard {
public void guardFeatureExists(
final DeviceFeatureStatuses deviceFeatureStatuses,
final Collection<PremiumFeatureStatus> premiumFeatureStatuses) {
final List<String> featureIds = premiumFeatureStatuses.stream()
.map(featureIsPremium -> featureIsPremium.featureId().toString())
.toList();
final List<String> missingIds = deviceFeatureStatuses.deviceFeatureStatus().stream().map(
dfs -> dfs.featureId().toString().toLowerCase()
).filter(dfs -> !featureIds.contains(dfs)).toList();
if(featureIds.isEmpty() || !missingIds.isEmpty()) {
throw new FeatureNotFoundDomainException(deviceFeatureStatuses.deviceId(), missingIds);
}
}
}
Finally, the Use Case utilizes this logic:
final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
try {
featureGuard.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
} catch (final FeatureNotFoundDomainException ex) {
throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
}
Similarly, for Exceptions, simply define them in the layer they belong to:
FeatureNotFoundDomainExceptionis located indomain.exception.FeatureStatusApiExceptionis located inapplication.exception.
To summarize the process of Step 3:
- Define Domain Models and Mappers to ensure correct data format.
- Define Ports and Adapters to ensure external data can enter.
- 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 “Premium”. We can see that the original code relied heavily on external data structures:
private static void guardFeatureIsPremium(
final DeviceFeatureStatusesModel featureStatusModel,
final Collection<CatalogFeature> catalogFeatures) {
final List<CatalogFeature> premiumFeatures = catalogFeatures.stream()
.filter(cf -> cf.getTags().stream()
.anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_PREMIUM.value())))
.toList();;
// Filter Request for any non-Premium features
final List<String> nonPremiumFeatures = featureStatusModel.featureStatusModel()
.stream()
.map(fs -> fs.featureId().toString().toLowerCase())
.filter(fid -> premiumFeatures.stream()
.map(pf -> pf.getFeatureId().toLowerCase())
.noneMatch(fid::equals))
.toList();
if (!nonPremiumFeatures.isEmpty()) {
final DeviceId deviceId = featureStatusModel.deviceId();
throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, nonPremiumFeatures);
}
}
Thanks to the Domain data model PremiumFeatureStatus 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;
import java.util.Collection;
import java.util.List;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.exception.PremiumFeatureNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
public class PremiumFeatureGuard {
public static void guardFeatureIsPremium(
final DeviceFeatureStatuses deviceFeatureStatuses,
final Collection<PremiumFeatureStatus> premiumFeatureStatuses) {
final List<String> premiumFeatures = premiumFeatureStatuses
.stream()
.filter(PremiumFeatureStatus::isPremium)
.map(feature -> feature.featureId().toString())
.toList();
final List<String> nonPremiumFeatures = deviceFeatureStatuses
.deviceFeatureStatus()
.stream()
.map(dfs -> dfs.featureId().toString().toLowerCase())
.filter(fid -> premiumFeatureStatuses.stream()
.map(pfs -> pfs.featureId().toString())
.noneMatch(fid::equals)).toList();
if (!nonPremiumFeatures.isEmpty()) {
final DeviceId deviceId = deviceFeatureStatuses.deviceId();
throw new PremiumFeatureNotFoundDomainException(deviceId, nonPremiumFeatures);
}
}
}
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;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.example.foundation.core.FeatureId;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
public class PremiumFeatureToStatusMapper {
public DeviceFeatureStatuses toDeviceFeatureStatuses(
final DeviceFeatureStatuses deviceFeatureStatuses,
final Collection<PremiumFeatureStatus> premiumFeatureStatuses
) {
final Map<String, String> mappedFeatureIds = premiumFeatureStatuses
.stream()
.collect(Collectors.toMap(
feature -> feature.featureId().toString().toLowerCase(),
feature -> feature.featureId().toString(),
(existingValue, newValue) -> existingValue));
final List<DeviceFeatureStatus> correctlyCapitalizedFeatureId =
deviceFeatureStatuses.deviceFeatureStatus()
.stream()
.map(dfs -> {
final String featureId = mappedFeatureIds.get(dfs.featureId().toString().toLowerCase());
return new DeviceFeatureStatus(FeatureId.of(featureId), dfs.isActive());
}).toList();
return new DeviceFeatureStatuses(deviceFeatureStatuses.deviceId(),
correctlyCapitalizedFeatureId);
}
}
In the Use Case, convert and store.
final boolean statusHasChanged = featureStatusRepositoryPort.save(premiumFeatureToStatusMapper.toDeviceFeatureStatuses(deviceFeatureStatuses,premiumFeatureStatuses));
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;
import java.util.List;
public interface NotificationServicePort {
void invalidateFeatureCacheForDevices(List<String> deviceIds);
}
package com.example.featuresystem.infrastructure.adatper.outbound;
import java.util.List;
import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.services.NotificationService;
public class NotificationServiceAdapter implements NotificationServicePort {
private final NotificationService notificationService;
public NotificationServiceAdapter(final NotificationService notificationService) {
this.notificationService = notificationService;
}
@Override
public void invalidateFeatureCacheForDevices(final List<String> deviceIds) {
notificationService.invalidateCacheForDevices(deviceIds);
}
}
Consolidation and Organization
Finally, integrating all steps, we get the final Use Case.
package com.example.featuresystem.application.usecase;
import java.util.List;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;
import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;
import com.example.featuresystem.domain.mapper.PremiumFeatureToStatusMapper;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.domain.service.PremiumFeatureGuard;
import com.example.featuresystem.domain.service.FeatureGuard;
import com.example.featuresystem.domain.service.DeviceValidator;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class UpdateFeatureStatus {
// outbound port
private final DeviceInventoryPort deviceInventoryPort;
private final CatalogFeaturePort catalogFeaturePort;
private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
private final NotificationServicePort notificationServicePort;
// domain service
private final DeviceValidator deviceValidator;
private final FeatureGuard featureGuard;
private final PremiumFeatureGuard premiumFeatureGuard;
private final PremiumFeatureToStatusMapper premiumFeatureToStatusMapper;
public UpdateFeatureStatus(final DeviceInventoryPort deviceInventoryPort,
final CatalogFeaturePort catalogFeaturePort,
final FeatureStatusRepositoryPort featureStatusRepositoryPort,
final NotificationServicePort notificationServicePort,
final DeviceValidator deviceValidator,
final FeatureGuard featureGuard,
final PremiumFeatureGuard premiumFeatureGuard,
final PremiumFeatureToStatusMapper premiumFeatureToStatusMapper) {
this.deviceInventoryPort = deviceInventoryPort;
this.catalogFeaturePort = catalogFeaturePort;
this.featureStatusRepositoryPort = featureStatusRepositoryPort;
this.notificationServicePort = notificationServicePort;
this.deviceValidator = deviceValidator;
this.featureGuard = featureGuard;
this.premiumFeatureGuard = premiumFeatureGuard;
this.premiumFeatureToStatusMapper = premiumFeatureToStatusMapper;
}
public void handle(final DeviceFeatureStatuses deviceFeatureStatuses) {
final DeviceId deviceId = deviceFeatureStatuses.deviceId();
// Step 2
final boolean exists = deviceInventoryPort.deviceExists(deviceId);
try {
deviceValidator.guardDeviceExists(deviceId, exists);
} catch (final DeviceNotFoundDomainException ex) {
log.warn("No device found for id '{}'", deviceId);
throw FeatureStatusApiException.unknownDevice(deviceId);
}
// Step 3
final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
try {
featureGuard.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
} catch (final FeatureNotFoundDomainException ex) {
throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
}
// Step 4.
try {
premiumFeatureGuard.guardFeatureIsPremium(deviceFeatureStatuses, premiumFeatureStatuses);
} catch (final FeatureNotFoundDomainException ex) {
throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, ex.getMissingFeatureIds());
}
// Step. 5
final boolean statusHasChanged = featureStatusRepositoryPort.save(
premiumFeatureToStatusMapper.toDeviceFeatureStatuses(deviceFeatureStatuses,premiumFeatureStatuses));
if (statusHasChanged) {
log.debug("Invalidating FeatureCache for DeviceId={}", deviceId);
notificationServicePort.invalidateFeatureCacheForDevices(List.of(deviceId.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;
import java.util.List;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;
import com.example.featuresystem.domain.exception.PremiumFeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.domain.service.FeatureStatusService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class UpdateFeatureStatus {
// outbound port
private final DeviceInventoryPort deviceInventoryPort;
private final CatalogFeaturePort catalogFeaturePort;
private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
private final NotificationServicePort notificationServicePort;
// domain service
private final FeatureStatusService featureStatusService;
public UpdateFeatureStatus(final DeviceInventoryPort deviceInventoryPort,
final CatalogFeaturePort catalogFeaturePort,
final FeatureStatusRepositoryPort featureStatusRepositoryPort,
final NotificationServicePort notificationServicePort,
final FeatureStatusService featureStatusService
) {
this.deviceInventoryPort = deviceInventoryPort;
this.catalogFeaturePort = catalogFeaturePort;
this.featureStatusRepositoryPort = featureStatusRepositoryPort;
this.notificationServicePort = notificationServicePort;
this.featureStatusService = featureStatusService;
}
public void handle(final DeviceFeatureStatuses deviceFeatureStatuses) {
final DeviceId deviceId = deviceFeatureStatuses.deviceId();
final boolean exists = deviceInventoryPort.deviceExists(deviceId);
try {
featureStatusService.guardDeviceExists(deviceId, exists);
final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
featureStatusService.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
featureStatusService.guardFeatureIsPremium(deviceFeatureStatuses, premiumFeatureStatuses);
final boolean statusHasChanged = featureStatusRepositoryPort.save(
featureStatusService.toDeviceFeatureStatuses(deviceFeatureStatuses, premiumFeatureStatuses));
if (statusHasChanged) {
log.debug("Invalidating FeatureCache for DeviceId={}", deviceId);
notificationServicePort.invalidateFeatureCacheForDevices(List.of(deviceId.toString()));
}
} catch (final DeviceNotFoundDomainException ex) {
log.warn("No device found for id '{}'", deviceId);
throw FeatureStatusApiException.unknownDevice(deviceId);
} catch (final FeatureNotFoundDomainException ex) {
throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
} catch (final PremiumFeatureNotFoundDomainException ex) {
throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, ex.getMissingFeatureIds());
}
}
}
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.featuresystem.application.port.inbound;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;
public interface FeatureStatusQueryPort {
DeviceFeatureStatuses apply(@NonNull final DeviceId deviceId);
}
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;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.example.foundation.core.FeatureId;
import com.example.foundation.core.DeviceId;
// Using standard JaxRs or Spring annotations instead of proprietary components
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.usecase.UpdateFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.infrastructure.adatper.inbound.rest.dto.DeviceFeatureStatusesRequest;
import org.springframework.stereotype.Component;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Path("/")
@Component
public class PremiumFeatureStatusResource {
private final UpdateFeatureStatus updateFeatureStatus;
public PremiumFeatureStatusResource(final UpdateFeatureStatus updateFeatureStatus) {
this.updateFeatureStatus = updateFeatureStatus;
}
@PUT
@Path("devices/{deviceId}/provisioned")
@Consumes({MediaType.APPLICATION_JSON})
public Response updateFeatureStatusForDevices(
@PathParam("deviceId") final DeviceId deviceId,
@Valid final DeviceFeatureStatusesRequest subscriptionStatusRequest) {
log.info("Updating feature statuses for DeviceId: {}", deviceId);
updateFeatureStatus.handle(mapToDomainModel(deviceId, subscriptionStatusRequest.features()));
log.debug("Successfully updated feature statuses");
return Response.ok().build();
}
private static DeviceFeatureStatuses mapToDomainModel(final DeviceId deviceId,
final Map<String, Boolean> featureStatusList) {
final List<DeviceFeatureStatus> deviceFeatureStatusModels = new ArrayList<>();
featureStatusList.forEach((featureId, isActive) ->
deviceFeatureStatusModels.add(new DeviceFeatureStatus(FeatureId.of(featureId),
isActive)));
return new DeviceFeatureStatuses(deviceId, deviceFeatureStatusModels);
}
}
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 DeviceFeatureStatusesRequest, 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.featuresystem.infrastructure.adatper.inbound.rest.dto;
import java.util.Map;
public record DeviceFeatureStatusesRequest(Map<String, Boolean> features) {
}
Here is the relevant folder structure:
└── infrastructure
└── adatper
├── inbound
│ └── rest
│ ├── PremiumFeatureStatusResource.java -> resource
│ └── dto
│ └── DeviceFeatureStatusesRequest.java -> DTO
└── outbound
├── mapper
│ └── CatalogFeatureToPremiumFeatureStatusMapper.java
├── CatalogFeatureAdapter.java
├── NotificationServiceAdapter.java
└── DeviceInventoryAdapter.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
│ │ ├── NotificationServicePort.java
│ │ └── DeviceInventoryPort.java
│ └── usecase
│ ├── FeatureStatusQueryUseCase.java
│ ├── ResetFeatureStatusUseCase.java
│ └── UpdateFeatureStatus.java
├── domain
│ ├── exception
│ │ ├── PremiumFeatureNotFoundDomainException.java
│ │ ├── FeatureNotFoundDomainException.java
│ │ └── DeviceNotFoundDomainException.java
│ ├── model
│ │ ├── PremiumFeatureStatus.java
│ │ ├── DeviceFeatureStatuses.java
│ │ └── DeviceFeatureStatus.java
│ └── service
│ └── FeatureStatusService.java
└── infrastructure
└── adapter
├── inbound
│ └── rest
│ ├── PremiumFeatureStatusResource.java
│ └── dto
│ └── DeviceFeatureStatusesRequest.java
└── outbound
├── mapper
│ └── CatalogFeatureToPremiumFeatureStatusMapper.java
├── CatalogFeatureAdapter.java
├── NotificationServiceAdapter.java
└── DeviceInventoryAdapter.javajava
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.