Let's continue our journey together to master complexity in software development. In my last article, I gave an insight into my personal way of thinking and approach when implementing a feature or use case. I showed that it is always a process. Namely one from "it works" to "it is robust, sustainable, and maintainable".
Vertical Slice Architecture: A Flexible Approach
In my post today, I want to show how we can implement a domain-specific handler. I would like to note up front that we are still following the path of a Vertical Sliced architecture. And for good reason, because this approach allows us to be flexible. In my article "Rethinking Architectural Boundaries: From Layered to Vertical Slice Architecture", I described my evolution from an advocate of strict layered architecture to more flexible approaches such as vertical slices. I also want to remind you that there is not always a right and a wrong, and that the world is not black and white. Often there are nuances and the choice of the right architectural approach depends on circumstances such as business requirements, team background and skills, organizational structure, and so on.
Vertical slices give us the flexibility in how we implement a particular use case. In one case, in good old CRUD fashion, we might just need to take a simple request and store the payload in a database. This does not require any service classes, maybe just the DbContext or even just a SQL statement. In another case we need more logic and a dedicated domain service is required. What I'm saying is that we don't have to commit, we can decide on a case-by-case basis and choose the best approach.
But what about overarching functions that are more or less relevant for different use cases? This is what I want to discuss in this article.
And because such things always sound rather abstract and the concrete implementation raises a lot of questions, I would like to show an example that I recently implemented in my current project and explain my approach.
The Dilemma of Service Classes
But before we get started, let me say a few words about service classes. For many years, I have been a proponent of keeping layers lean and either putting the logic in domain services. Unfortunately, like the controllers I often criticize, service classes tend to become fat, messy containers. This is often simply because we very quickly think, "Oh, we need an OrderService." We then "collect" everything in there that has to do with orders-that is, that somehow belongs to it. I don't think that's a very good idea anymore, because, in the end, it's just a collection of methods in a class. The Single Responsibility Principle is usually broken quickly. So we need a better approach that can withstand an ever-increasing number of use cases.
Of course, the way to look at it is that if we separate the responsibilities of the layers, we can separate the concerns well. For example, the domain model knows what business rules to apply, but nothing about persistence. The repository knows that, but nothing about the domain logic, and so on. However, this does not prevent classes from becoming larger and larger as containers, and from being extended with new methods, which can lead to violation of the single responsibility.
Case Study: Streamlining Company Data with CountryFinder
In my example, I would like to follow up on my last article, in which I described the transformation of incoming requests into commands in the context of the Vertical Slice Architecture approach. That article focused on structuring the solution, mapping, and applying commands using MediatR in .NET.
In this use case, the following issue appeared regarding the transformation of data from the request from another system into our system.
Let me briefly explain what this is all about. The third party service publishes an event with information about a company. Among other things, this event contains the country, as you can see in the following example payload.
{
"name": "My Test Company SL",
"address": {
"street_address": "Carretera Cádiz-Málaga 16",
"zip_code": "20808",
"state": "Guipúzcoa",
"country": "España",
"city": "Getaria"
},
// More attributes
...
}However, the country can be in any language in the request payload, for example "España" instead of "Spain". In my domain model, however, the country information refers to an ISO 3166–1 alpha-2 standardized list of countries consisting of country code and name, see the following example.

Regardless, I have to search and find the correct entry for saving anyway to assign the correct country code to the record being saved.
As you might have guessed, this is already code that I definitely don't want in my CreateOrUpdateCompanyHandler. Not even as a private method, because that would result in a huge class with many dependencies and too much knowledge.
See the following figure as a sketch of what the handler should do.
public class CreateOrUpdateCompanyHandler : IRequestHandler<CreateOrUpdateCompanyCommand>
{
private readonly DbContext _context;
public CreateOrUpdateCompanyHandler(
DbContext context)
{
_context = context;
}
public async Task Handle(CreateOrUpdateCompanyCommand command, CancellationToken cancellationToken)
{
// Step 1: Validate and find country
...
// Step 2: Check if the company already exists
...
// Step 3: Update or create company
...
// Step 4: Save changes
await _context.SaveChangesAsync(cancellationToken);
}
}What I need here is a small, reusable component that can return the correct country entity using any language. And to meet the single responsibility requirement, this "thing" should be able to do just that. I have in mind something like a CountryFinder thing.
My first thought was that I could create repositories. A country repository and a company repository and put everything that has to do with querying and writing to the database in there. But would that do much good? I don't think so, because it wouldn't really add any value in terms of the handler needing anything to validate and transform the command data. A company repository would also basically just become a collection of methods in terms of interacting with the company data model/database.
As long as I don't have or need a domain model, I can live with injecting the DbContext into the handler and storing the generated data object.
For determining the country entry, a repository would not be sufficient anyway, because I need some logic to determine the country entity based on the input value. The input value could basically be the country in English, the country code, or the country in another language.
But let's go one step at a time. We still have to solve the problem of how to find the entry "ES — Spain" in our ISO-based table with the input "España".
Building the Country Translation Entity
For this, I built the following solution. To solve this requirement in an extensible way, I created a CountryTranslation entity that maps non-ISO country names to ISO country codes. I want to use this entity later in CountryFinder to resolve country names to ISO codes before we look up the Countries table.
public class CountryTranslation
{
public string NonIsoName { get; set; }
public string IsoCode { get; set; }
}The entries in the database will look like the following.

With this I have created the prerequisites to map country names in other languages to the ISO country code. This leads to the following idea how the CountryFinder should work.
public class CountryFinder
{
private readonly DbContext _context;
public CountryFinder(DbContext context)
{
_context = context;
}
public async Task<CountryData?> FindCountryAsync(string input)
{
// First, try to resolve non-ISO country name to ISO code
...
// Then, try to find by ISO code
...
// Finally, try to find by name
return country;
}
}Where Does the CountryFinder Belong?
But before I implement this component, I ask myself, where does the CountryFinder belong? As I said, it is a reusable unit that does exactly one thing to ensure the Single Responsibility Principle.
Of course, I could choose to let the CountryFinder become part of the "CreateOrUpdateCompany" slice and only use it there. However, it looks to me like we could make good use of the Finder in other use cases as well, which is why I see it as reusable and see another place for it. This is because in a Vertical Slice Architecture, each slice (or feature or use case) should be isolated and independent. However, certain common functions, such as the CountryFinder, can be used in different slices. If I were to put the CountryFinder logic in each slice, it would preserve isolation, but could result in duplicate code. I would generally not recommend doing this because of the potential code duplication.
I chose to create a shared folder within the application layer to store utilities or services like CountryFinder that can be used in different slices. The application layer, including the slices, is structured as follows.
- Application
- Company
- CreateOrUpdateCompany
- Shared
- CountryFinderDistinguishing Between Application and Infrastructure Layers
Let me explain why I decided to put the CountryFinder in the application layer rather than the infrastructure layer, because sometimes the distinction between application and infrastructure layers can be a bit fuzzy, and the decision may depend on the specifics of the application and architecture.
In my opinion, in the Domain-Driven Design approach and a Vertical Sliced Architecture, finders can be considered part of the application layer if they are primarily used to support business use cases and workflows. The application layer is where tasks are coordinated, business rules are handled, and domain entities are orchestrated to meet a specific business need.
However, if finders are only responsible for abstracting data access logic and have no business logic, they can be considered part of the infrastructure layer. The infrastructure layer is where you typically handle concerns such as database access, file system access, and other external integrations.
If the Finder implementations just query the database and return database objects with no business logic or transformations, they can be considered part of the infrastructure layer. However, if they contain business rules or transformations specific to the needs of the application, they would be part of the application layer.
Implementation of CountryFinder
In the following implementation of my CountryFinder we see that there is logic in there, which is why I clearly assigned it to the application layer.
public class CountryFinder : ICountryFinder
{
private readonly IntentDbContext _context;
public CountryFinder(IntentDbContext context)
{
_context = context;
}
public async Task<CountryData?> FindCountryAsync(string input)
{
var isoCode = await _context.CountryTranslations
.Where(ct => ct.NonIsoName.Equals(input))
.Select(ct => ct.IsoCode)
.FirstOrDefaultAsync();
if (isoCode != null)
{
input = isoCode;
}
// Then, try to find by ISO code
var country = await _context.Countries
.FirstOrDefaultAsync(c => c.Code.Equals(input));
if (country != null)
{
return country;
}
// Finally, try to find by name
country = await _context.Countries
.FirstOrDefaultAsync(c => c.Name.Equals(input));
return country;
}
}To use CountryFinder, it must be registered as a service. For this, the following line must be added to Program.cs as follows.
builder.Services.AddTransient<ICountryFinder, CountryFinder>();We can now inject the service into our RequestHandler and use it. The updated implementation will now look like this.
public class CreateOrUpdateCompanyHandler : IRequestHandler<CreateOrUpdateCompanyCommand>
{
private readonly IntentDbContext _context;
private readonly CompanyDataFactory _companyDataFactory;
private readonly ILogger<CreateOrUpdateCompanyHandler> _logger;
private readonly ICountryFinder _countryFinder;
public CreateOrUpdateCompanyHandler(
ICountryFinder countryFinder,
IntentDbContext context,
CompanyDataFactory companyDataFactory,
ILogger<CreateOrUpdateCompanyHandler> logger)
{
_logger = logger;
_countryFinder = countryFinder;
_context = context;
_companyDataFactory = companyDataFactory;
}
public async Task Handle(CreateOrUpdateCompanyCommand command, CancellationToken cancellationToken)
{
try
{
// Step 1: Validate and find country
var country = await _countryFinder.FindCountryAsync(command.Address?.Country);
if (country == null)
{
throw new InvalidOperationException("Country not found.");
}
// Step 2: Check if the company already exists
...
// Step 3: Update or create company
...
// Step 4: Save changes and return company ID
await _context.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
_logger.LogError(exception.Message);
}
}
}Conclusion
And there we have it, our journey through the creation and deployment of the CountryFinder component within the Vertical Slice architecture. Through this process, we've unpacked the complexities and implications of software development and taken a deep dive into the decision-making process that shapes the architecture of our software.
Through careful step-by-step analysis, we've explored the delicate balance between maintaining a clean, single responsibility principle and the need for reusable components in a Vertical Slice Architecture. We've discussed the importance of flexibility and the need to make informed decisions based on the unique requirements of each use case.
As we conclude this article, let this serve as a reminder that the path to mastering complexity in software development is a journey, not a destination. It's a continuous process of learning, adapting, and improving our approach.
Cheers!