High-Level View of the Codebase
Taskly is intentionally small so you can focus on the architectural patterns. The solution follows a Clean Architecture slice: Domain (pure logic), Application (use cases), Infrastructure (adapters), and Main (composition root).
Warning
Keep the editor open and inspect the source while you read—simply skimming these pages will not cement the ideas.
Folder Layout Cheat Sheet
Taskly.Domain
Pure business rules: entities, value objects, domain exceptions, and repository ports.
Taskly.Application
Use cases, input/output models, and query contracts that orchestrate the domain.
Taskly.Infrastructure
Adapters for persistence, APIs, and notifications that fulfill the ports.
Taskly.Main
Composition root where ASP.NET Core wiring and dependency injection live.
tests/UnitTests
Fast tests for domain invariants and application behaviors using mocks.
Layer Overview
flowchart TD
Client[Web API] --> UC[Application Use Cases]
UC --> Domain[Domain Model]
UC --> Ports[Ports Interfaces]
Ports --> Infra[Infrastructure Adapters]
Keyword Cheat Sheet
Entity
A domain object with identity and behavior (e.g., TodoList).
Value Object
Immutable concept represented by value, such as DueDate.
Repository
A port interface that describes persistence operations (e.g., ITodoListRepository).
Dependency Injection
Runtime wiring that binds ports to adapters inside Taskly.Main.
Domain Logic
Pure decisions encoded inside entities and value objects without framework dependencies.
Infrastructure Logic
Technology-specific code (SQL, HTTP, logging) kept inside adapters.
One-Screen Quickstart
Run the API
Requires Docker for MySQL + .NET 8 SDK.
docker compose -f config/docker/docker-compose.yml up -d
DOTNET_ENVIRONMENT=Development dotnet run --project src/Taskly.Main
# Swagger UI: http://localhost:8000/swagger/index.html
Run the Tests
Fast safety net before changing anything.
dotnet test
# Optional cleanup
# dotnet clean && dotnet build
Try it yourself
Trace a request in Swagger. For each API operation, jot down which use case and repository it touches.
Ports & Adapters (High Level + Code Snippets)
Ports are stable contracts owned by the Domain and Application layers. Adapters are technology-specific implementations in Infrastructure that satisfy those contracts. Together they form the plug-and-play seam of Clean Architecture.
Understanding Ports
Ports are the stable, narrow interfaces that define how the core of your application (Domain and Application layers) interacts with the outside world. They focus on business needs, not technical details.
What Makes Ports Special?
- Business-Focused: Expressed in domain language, free from framework, database, or transport specifics.
- Stable Contracts: Once defined, they change rarely, ensuring long-term compatibility.
- Testable: Interfaces allow easy mocking for unit tests without external dependencies.
Types of Ports in Taskly
Taskly distinguishes between two port types to maintain clean separation:
- Input Ports: Like the
IUseCaseinterface, called by driving adapters (e.g., API controllers) to trigger business actions. - Output Ports: Such as
ITodoListRepositoryor query contracts, used by use cases to access infrastructure (e.g., databases).
This design keeps the Domain pure and allows seamless swapping of technologies on either side.
graph LR
DrivingAdapter["Driving Adapter
(CreateTodoListController)"] -- calls --> InputPort(("Input Port
(IUseCase)"))
InputPort -- implemented by --> UseCase["Use Case
(CreateTodoList)"]
UseCase -- uses --> OutputPort(("Output Port
(ITodoListRepository)"))
UseCase -- calls --> Entity["Entity
(TodoList)"]
OutputPort -- implemented by --> DrivenAdapter["Driven Adapter"]
subgraph DomainLayer ["Domain"]
Entity
end
subgraph ApplicationCore ["Application"]
InputPort
UseCase
OutputPort
end
subgraph Infrastructure ["Infrastructure"]
DrivingAdapter
DrivenAdapter
end
flowchart LR
CreateTodoList[[CreateTodoList Use Case]] --> ITodoListPort[(ITodoListRepository)]
ITodoListPort --> TodoList[TodoList Aggregate]
ITodoListPort -. "implemented by" .-> AdoRepo[AdoTodoListRepository]
AdoRepo --> SQL[(SQL Database)]
CreateTodoList depends on ITodoListRepository, implemented by AdoTodoListRepository.Designing Adapters
Adapters are the technology-facing counterparts to ports. Driving adapters (like Minimal API endpoints) translate transport details into input models and call a use case (the input port). Driven adapters (like repositories and query adapters) implement output ports and translate between the core and the outside world: SQL, HTTP, files, or messaging.
Key Principles
- Translate, don’t decide: Apply business rules in the use case, not in adapters.
- Depend on ports: Constructors accept interfaces, factories, and configuration—not controllers.
- Handle failures: Add timeouts, retries where appropriate, and wrap transient issues.
- Observe I/O: Log and trace around external calls for diagnosability.
Responsibilities
Keep adapters thin and focused on translation and resilience. They manage connections, transactions, retries, and timeouts; map between domain types and storage; and surface errors as domain-friendly failures. All decisions about what to do belong in the use case—adapters only describe how to do it for a given technology.
sequenceDiagram title Adapter Collaboration Overview participant Port as Port Interface participant Adapter as Adapter participant System as External System Port->>Adapter: Persist(todoList) Adapter->>System: SQL Command / API Call System-->>Adapter: Result / Error Adapter-->>Port: Success or Failure
sequenceDiagram title TodoLists Adapter Walkthrough participant UseCase as CreateTodoList participant Port as ITodoListRepository participant Adapter as AdoTodoListRepository participant Db as SQL Database UseCase->>Port: Save(todoList) Port->>Adapter: Persist(todoList) Adapter->>Db: EXEC usp_SaveTodoList Db-->>Adapter: Rows affected Adapter-->>UseCase: Completed
ITodoListRepository by calling SQL stored procedures.
---
title: Use Case to Adapter Example
---
flowchart LR
CreateTodoList[[CreateTodoList]] --> ITodoListRepo[(ITodoListRepository)]
SearchLists[[SearchAllTodoListsByUserId]] --> IListQuery[(ITodoListByIdAndUserIdQuery)]
ITodoListRepo -. "implemented by" .-> AdoRepo[AdoTodoListRepository]
IListQuery -. "implemented by" .-> QueryAdapter[TodoListByIdAndUserIdQuery]
AdoRepo --> Sql[(SQL Database)]
QueryAdapter --> Sql
---
title: Ports and Adapters Interaction
---
flowchart TB
subgraph Application
UseCase[Use Case]
Port[(Port Interface)]
end
subgraph Infrastructure
Adapter[Adapter Implementation]
end
UseCase -->|depends on| Port
Adapter -->|implements| Port
Adapter -->|uses| External[(External System)]
Rules to Live By
- Domain owns the interface: Adapters implement ports; they do not invent their own signatures.
- One adapter per responsibility: Keep persistence separate from messaging to avoid tangled dependencies.
- Only cross via ports: Use cases speak to infrastructure exclusively through port abstractions.
Best Practices
- Codify mapping: Centralize conversions between domain entities and DTOs to keep adapters lean.
- Instrument adapters: Add logging around I/O boundaries so failures are diagnosable.
- Mock ports in tests: Unit tests cover use cases via fake implementations, proving the seam works.
A Port (ITodoListRepository) with Its Adapter (AdoTodoListRepository)
This interface defines the contract for the TodoList repository port in the Domain layer, specifying methods for retrieving TodoList entities by ID, user, or status, and for saving or removing them. It ensures the Domain remains pure by abstracting persistence operations.
public interface ITodoListRepository
{
Task<TodoList?> ById(Guid todoListId);
Task<TodoList?> ByIdAndTrashStatus(Guid todoListId, bool inTrash);
Task<IReadOnlyCollection<TodoList>> ByUserId(Guid userId);
Task Save(TodoList todoList);
Task Remove(TodoList todoList);
}
This adapter implements the ITodoListRepository interface in the Infrastructure layer using ADO.NET to interact with the SQL database. It translates domain operations into SQL queries, handles data mapping between domain entities and database records, and manages database connections and transactions.
public async Task<TodoList?> ById(Guid todoListId)
{
DbParameter id = CreateParameter("@Id", todoListId.ToString().ToLower());
DbDataReader dbDataReader = await ExecuteReaderAsync(SELECT_TODO_LIST_BY_ID_WITH_ITEMS, [id]);
try
{
if (await dbDataReader.ReadAsync())
{
var title = dbDataReader.GetString(dbDataReader.GetOrdinal(COL_TITLE));
var isLocked = dbDataReader.GetBoolean(dbDataReader.GetOrdinal(COL_IS_LOCKED));
var userId = dbDataReader.GetGuid(dbDataReader.GetOrdinal(COL_USER_ID));
var inTrash = dbDataReader.GetBoolean(dbDataReader.GetOrdinal(COL_IN_TRASH));
var items = new List<TodoItem>();
do
{
if (!await dbDataReader.IsDBNullAsync(dbDataReader.GetOrdinal(COL_ITEM_ID)))
{
items.Add(new TodoItem(
dbDataReader.GetGuid(dbDataReader.GetOrdinal(COL_ITEM_ID)),
dbDataReader.GetString(dbDataReader.GetOrdinal(COL_DESCRIPTION)),
dbDataReader.GetBoolean(dbDataReader.GetOrdinal(COL_IS_DONE)),
dbDataReader.GetDateTime(dbDataReader.GetOrdinal(COL_DUE_DATE))));
}
} while (await dbDataReader.ReadAsync());
return new TodoList(todoListId, userId, title, isLocked, inTrash, items);
}
return null;
}
finally
{
await dbDataReader.DisposeAsync();
}
}
Try it yourself
Mock ITodoListRepository in a unit test and confirm a use case works without touching the database.
In-Depth (Goal • Why • How)
Each layer has a clear goal. Follow the arrows: requests enter through the Web API, flow to use cases, touch domain entities, and finally exit through adapters.
sequenceDiagram title CreateTodoList Execution Path participant API as API Endpoint participant UC as Use Case participant Domain as Domain Entity participant Port as Port Interface participant Repo as Repository Adapter API->>UC: Execute(CreateTodoList) UC->>Port: Request user & persistence Domain-->>UC: Validated TodoList UC->>Repo: Save(todoList) UC-->>API: TodoListId
Domain Layer
The Domain expresses business rules with entities (TodoList, TodoItem), value objects (DueDate), and domain-specific exceptions. It is free of frameworks, which means every rule can be unit tested without bootstrapping infrastructure—just instantiate entities and value objects and assert behavior.
This class (constructor shown) represents the TodoList entity in the Domain layer, encapsulating business logic for creating and managing todo lists, including validation of user IDs and titles, and managing associated todo items.
public TodoList(Guid id, Guid userId, string title, bool isLocked, bool inTrash, List<TodoItem>? items = default)
{
EnsureUserIdIsValid(userId);
EnsureTodoListTitleIsValid(title);
Id = id;
UserId = userId;
Title = title;
IsLocked = isLocked;
InTrash = inTrash;
_items = items ?? [];
}
This value object represents a due date in the Domain layer, ensuring due dates are not set in the past and providing a status method to check if the date is expired, upcoming, or none.
public sealed class DueDate
{
public DateTime Date { get; }
public DueDate(DateTime dueDate)
{
EnsureDueDateIsNotInThePast(dueDate);
Date = dueDate;
}
public DueDateStatus Status()
=> Date <= DateTime.Now.AddSeconds(-2)
? DueDateStatus.EXPIRED
: Date < DateTime.Now.AddDays(7)
? DueDateStatus.UPCOMING
: DueDateStatus.NONE;
}
Why purity matters
No database calls, HTTP clients, or logging frameworks live here. Pure code means easier unit tests and reuse—construct a domain type, execute a method, and assert the outcome without mocking infrastructure.
Application Layer (Use Cases)
Use cases are the traffic controllers of Taskly. They coordinate inputs, call domain logic, interact with ports, and shape the outputs without ever leaking technology concerns. Because dependencies are injected as interfaces, you can unit test handlers by mocking ports and asserting orchestration logic.
- Commands perform state changes and usually return IDs or nothing.
- Queries gather read models and never mutate domain state.
- Validation happens up front to protect domain invariants before reaching adapters.
- Logging and telemetry are injected dependencies, allowing observability without hard coupling.
---
title: Use Case Orchestration Steps
---
flowchart LR
Input[Input DTO]
Input --> Validate[Validate & Guard]
Validate --> DomainCall[Invoke Domain Entities]
DomainCall --> PortsCall[Invoke Ports & Queries]
PortsCall --> Output[Return Result DTO]
This use case handles the creation of a new TodoList, validating the user via the repository, constructing the domain entity, saving it through the repository port, logging the action, and returning the new TodoList ID.
public sealed record CreateTodoListInput(string Title, Guid UserId);
public sealed class CreateTodoList : IUseCase<CreateTodoListInput, Task<Guid>>
{
public async Task<Guid> Execute(CreateTodoListInput input)
{
User user = (await _userRepository.ById(input.UserId))
?? throw new ElementNotFoundException($"User with ID {input.UserId} not found.");
TodoList todoList = new(user.Id, input.Title);
await _todoListRepository.Save(todoList);
_logger.LogInformation("New todo list created with ID {TodoListId}", todoList.Id);
return todoList.Id;
}
}
This use case retrieves all TodoLists for a given user by delegating to a query port, logging the operation, and returning a collection of read models (TodoListData) optimized for the API.
public sealed record SearchAllTodoListsByUserIdInput(Guid UserId);
public sealed class SearchAllTodoListsByUserId
: IUseCase<SearchAllTodoListsByUserIdInput, Task<IReadOnlyCollection<TodoListData>>>
{
public async Task<IReadOnlyCollection<TodoListData>> Execute(SearchAllTodoListsByUserIdInput input)
{
_logger.LogInformation("Fetching all todo lists for user {UserId}", input.UserId);
return await _allTodoListsByUserIdQuery.Fetch(input.UserId);
}
}
Concept: CQRS lite
Commands return identifiers or void. Queries return read models defined in Taskly.Application.Contracts.Data.
Infrastructure Layer
Infrastructure maps ports to real technologies. Repositories translate between domain objects and tables, while query adapters craft read-optimized DTOs. Each adapter encapsulates persistence, messaging, or external API concerns, keeping technology-specific code centralized and swappable.
This adapter implements the ITodoListRepository interface in the Infrastructure layer using ADO.NET to interact with the SQL database. It translates domain operations into SQL queries, handles data mapping between domain entities and database records, and manages database connections and transactions.
public async Task Save(TodoList todoList)
{
string todoListQuery = INSERT_TODO_LIST;
if ((await ById(todoList.Id)) != null)
{
todoListQuery = UPDATE_TODO_LIST;
}
await ExecuteNonQueryAsync(todoListQuery, CreateTodoListParameters(todoList));
await ExecuteNonQueryAsync(DELETE_ALL_TODO_ITEMS, [CreateParameter("@TodoListId", todoList.Id.ToString().ToLower())]);
if (todoList.Items.Count > 0)
{
await BulkInsertTodoItems(todoList.Items, todoList.Id);
}
}
This query adapter implements the IAllTodoListsByUserIdQuery interface, executing SQL queries to fetch read models (TodoListData) from the database, mapping the results to DTOs optimized for the API.
public async Task<IReadOnlyCollection<TodoListData>> Fetch(Guid userId)
{
using var connection = _factory.CreateConnection()
?? throw new InvalidOperationException("DbProviderFactory returned a null DbConnection.");
connection.ConnectionString = _connectionString;
await connection.OpenAsync();
using var command = connection.CreateCommand();
var userIdParam = command.CreateParameter();
userIdParam.ParameterName = "@UserId";
userIdParam.Value = userId.ToString();
command.CommandText = QRY;
command.Parameters.Add(userIdParam);
using var reader = await command.ExecuteReaderAsync();
var todoLists = new Dictionary<Guid, TodoListData>();
while (await reader.ReadAsync())
{
var todoListIdOrd = reader.GetOrdinal("id");
var titleOrd = reader.GetOrdinal("title");
var todoListId = reader.GetGuid(todoListIdOrd);
if (!todoLists.TryGetValue(todoListId, out TodoListData? value))
{
value = new TodoListData(
Id: todoListId,
Title: reader.GetString(titleOrd),
IsLocked: reader.GetBoolean(reader.GetOrdinal("islocked")),
User: new UserData(
Id: reader.GetGuid(reader.GetOrdinal("userid")),
Name: reader.GetString(reader.GetOrdinal("username")),
Email: reader.GetString(reader.GetOrdinal("useremail"))
),
InTrash: reader.GetBoolean(reader.GetOrdinal("intrash")),
Items: []
);
todoLists[todoListId] = value;
}
if (!await reader.IsDBNullAsync(reader.GetOrdinal("itemid")))
{
value.Items.Add(new TodoItemData(
Id: reader.GetGuid(reader.GetOrdinal("itemid")),
Description: reader.GetString(reader.GetOrdinal("description")),
DueDate: reader.GetDateTime(reader.GetOrdinal("duedate")),
IsDone: reader.GetBoolean(reader.GetOrdinal("isdone"))
));
}
}
return todoLists.Values.ToList().AsReadOnly();
}
Why adapters stay thin
Adapters focus on translation only. Validation and business rules already happened in the Application layer.
Main / Composition Root
The composition root wires everything together. Dependency Injection chooses which adapter implements each port, configures ASP.NET Core hosting, and ensures each request resolves fresh scoped services.
This module configures dependency injection for persistence-related services, binding repository and query interfaces to their ADO.NET implementations, and injecting necessary dependencies like DbProviderFactory and connection strings.
return services
.AddScoped<IUserRepository, AdoUserRepository>(sp =>
new AdoUserRepository(
sp.GetRequiredService<DbProviderFactory>(),
_connectionString,
sp.GetRequiredService<ILogger<AdoUserRepository>>()))
.AddScoped<ITodoListRepository, AdoTodoListRepository>(sp =>
new AdoTodoRepository(
sp.GetRequiredService<DbProviderFactory>(),
_connectionString,
sp.GetRequiredService<ILogger<AdoTodoListRepository>>()))
.AddScoped<ITodoListByIdAndUserIdQuery, TodoListByIdAndUserIdQuery>(sp =>
new TodoListByIdAndUserIdQuery(
sp.GetRequiredService<DbProviderFactory>(),
_connectionString,
sp.GetRequiredService<ILogger<TodoListByIdAndUserIdQuery>>()));
This is the entry point of the application, setting up the ASP.NET Core web application, registering modules for persistence, web API, and use cases, and running the app asynchronously.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddPersistenceModule(builder.Configuration)
.AddWebApiModule(builder.Configuration)
.AddUseCases();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Infrastructure ready, DI bindings applied.");
}
await app
.UsePersistenceModule()
.UseWebApiModule()
.RunAsync();
This module configures the web API services, including Swagger documentation, endpoint exploration, and problem details handling for error responses.
services
.AddEndpointsApiExplorer()
.AddSwaggerGen(options =>
{
options.SwaggerDoc(configuration["WebApi:Version"], configuration.BuildOpenApiInfo());
})
.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
(context.ProblemDetails.Status, context.ProblemDetails.Title, context.HttpContext.Response.StatusCode) = context.Exception switch
{
ValidationException or ArgumentException => (400, "Validation Error", 400),
ElementNotFoundException => (404, "Not Found", 404),
_ => (context.ProblemDetails.Status, context.ProblemDetails.Title, 500)
};
};
});
Try it yourself
Follow a dependency from Program.cs to the adapter. Can you identify where the database connection string is injected?
Tutorial: Add a Command Feature (Step-by-Step)
Commands change state. Instead of inventing a brand-new feature, walk through the existing
CreateTodoList command to see how all layers collaborate. Then you can mirror the pattern for your own feature.
sequenceDiagram title CreateTodoList Command Flow participant Client participant Controller as Minimal API participant UseCase as CreateTodoList participant Users as IUserRepository participant Repo as ITodoListRepository Client->>Controller: POST /api/todolists Controller->>UseCase: Execute(CreateTodoListInput) UseCase->>Users: ById(UserId) UseCase->>Repo: Save(todoList) UseCase-->>Controller: New TodoList Id Controller-->>Client: 201 Created
Implementation Checklist
- Define the input DTO and handler inside
Taskly.Application. - Coordinate domain and port dependencies inside the handler.
- Surface the command through a Web API endpoint.
- Update the Routes.cs file in the infrastructure layer.
- Register the use case in dependency injection.
- Cover the behavior with unit tests using mocks.
1. Study the Input & Handler
This code snippet defines the command input record and the use case class. The record structures the input data immutably, ensuring type safety. The use case implements the IUseCase interface, orchestrating validation, domain logic, and persistence through injected ports, while logging the operation for observability.
public sealed record CreateTodoListInput(string Title, Guid UserId);
public sealed class CreateTodoList : IUseCase<CreateTodoListInput, Task<Guid>>
{
public async Task<Guid> Execute(CreateTodoListInput input)
{
User user = (await _userRepository.ById(input.UserId))
?? throw new ElementNotFoundException($"User with ID {input.UserId} not found.");
TodoList todoList = new(user.Id, input.Title);
await _todoListRepository.Save(todoList);
_logger.LogInformation("New todo list created with ID {TodoListId}", todoList.Id);
return todoList.Id;
}
}
This handler validates the user existence via a repository port, constructs a domain entity, saves it through another port, and logs the creation. It behaves as a command handler, ensuring business rules are enforced before state changes, and returns the new entity's ID for API responses.
2. Inspect the API Boundary
This controller method structures the API endpoint as a static function for minimal API integration, accepting a request body and injecting the use case. It behaves by performing basic validation on the user ID, executing the use case, and returning a Created response with the new resource location, keeping HTTP concerns separate from business logic.
public static async Task<Results<Created, BadRequest>> Invoke(
[FromBody] CreateTodoListBody body,
[FromServices] IUseCase<CreateTodoListInput, Task<Guid>> createTodoList)
{
if (body.UserId == Guid.Empty)
{
throw new ArgumentException("UserId cannot be empty.");
}
Guid todoListId = await createTodoList.Execute(new CreateTodoListInput(body.Title, body.UserId));
return TypedResults.Created($"/api/todolists/{todoListId}");
}
The controller stays thin by delegating to the use case, handling only transport-level validation and response formatting. It ensures the API boundary is clean, with error handling delegated to global middleware.
3. Follow the DI Registration
This module configures dependency injection by registering use case interfaces to their implementations as scoped services. Its structure uses a fluent API for adding services, ensuring each request gets fresh instances. It behaves by binding abstractions to concretes, allowing the composition root to resolve dependencies automatically without tight coupling.
return services
.AddScoped<IUseCase<CreateUserInput, Task<Guid>>, CreateUser>()
.AddScoped<IUseCase<CreateTodoListInput, Task<Guid>>, CreateTodoList>()
.AddScoped<IUseCase<AddItemToTodoListInput, Task<Guid>>, AddTodoItemToTodoList>();
The registration ensures that when the controller requests the IUseCase interface, the DI container provides the CreateTodoList implementation, enabling loose coupling and testability.
4. Update the Routes.cs file in the infrastructure layer
This file maps the Web API endpoints to their respective controllers, ensuring the command is accessible via HTTP. It registers routes for the CreateTodoList command, handling HTTP methods and paths.
public static class Routes
{
public static void MapTodoListRoutes(this WebApplication app)
{
app.MapPost("/api/todolists", CreateTodoListController.Invoke)
.WithName("CreateTodoList")
.WithOpenApi();
// Additional routes for other commands...
}
}
The Routes.cs file centralizes endpoint definitions, making it easy to manage and update API routes without scattering them across controllers.
5. Verify with Unit Tests
This unit test structures the test method with the [Fact] attribute for xUnit, setting up mock dependencies and test data. It behaves by executing the use case with a valid input, asserting that the result is a valid GUID, and verifying the repository's Save method was called, ensuring the command logic works independently of infrastructure.
[Fact]
public async Task Execute_WithValidInput_CreatesTodoListAndReturnsId()
{
var user = new User("John Doe", "john.doe@example.com", userId);
var mockTodoListRepository = new MockTodoListRepository();
var mockUserRepository = new MockUserRepository();
mockUserRepository.AddUser(user);
var useCase = new CreateTodoList(mockTodoListRepository, mockUserRepository, new MockLogger<CreateTodoList>());
var result = await useCase.Execute(new CreateTodoListInput("My Todo List", userId));
Assert.NotEqual(Guid.Empty, result);
var savedTodoList = await mockTodoListRepository.ById(result);
Assert.NotNull(savedTodoList);
}
The test uses mocks for ports to isolate the use case, proving that it correctly orchestrates domain and persistence operations without touching external systems.
Try it yourself
Extend CreateTodoList to send a notification via a new port. Sketch the port in the Domain layer, mock it in tests, and add a thin adapter in Infrastructure.
Tutorial: Add a Query Feature (Step-by-Step)
Queries return read models. Follow the existing SearchAllTodoListsByUserId flow to see how Taskly projects data without mutating domain state.
---
title: SearchAllTodoLists Query Flow
---
flowchart LR
Client["GET /api/todolists/{userId}
(Routes.cs)"]
Client --> Controller["Minimal API
(SearchAllTodoListsByUserIdController)"]
Controller --> UseCase[SearchAllTodoListsByUserId]
UseCase --> QueryPort[(IAllTodoListsByUserIdQuery)]
QueryPort --> Projection[TodoListData DTOs]
Implementation Checklist
- Describe the read operation via a query port.
- Implement the use case that delegates to the port.
- Materialize DTOs inside an infrastructure query adapter.
- Expose a minimal API endpoint.
- Update the Routes.cs file in the infrastructure layer.
- Verify projections through unit tests.
1. Define the Port & Use Case
This code snippet defines the query port interface and the use case class. The port is a simple interface with a single method for fetching data, promoting loose coupling. The use case implements the IUseCase interface, taking an input record, logging the operation, and delegating to the query port for data retrieval, ensuring separation of concerns between application logic and data access.
public interface IAllTodoListsByUserIdQuery
{
Task<IReadOnlyCollection<TodoListData>> Fetch(Guid userId);
}
This use case class structures the query execution by validating input implicitly through the record type, logging for observability, and returning a read-only collection of DTOs. It behaves as a thin orchestrator, relying on dependency injection for the query port, which allows for easy testing and swapping of implementations.
public sealed record SearchAllTodoListsByUserIdInput(Guid UserId);
public sealed class SearchAllTodoListsByUserId
: IUseCase<SearchAllTodoListsByUserIdInput, Task<IReadOnlyCollection<TodoListData>>>
{
public async Task<IReadOnlyCollection<TodoListData>> Execute(SearchAllTodoListsByUserIdInput input)
{
_logger.LogInformation("Fetching all todo lists for user {UserId}", input.UserId);
return await _allTodoListsByUserIdQuery.Fetch(input.UserId);
}
}
2. Explore the Query Adapter
This query adapter implements the port interface using ADO.NET for database interaction. Its structure includes connection management, parameterized queries for security, and data mapping from database rows to domain DTOs. It behaves by executing a SQL query asynchronously, handling potential null values, and aggregating results into a dictionary for efficient grouping before returning a read-only collection.
public async Task<IReadOnlyCollection<TodoListData>> Fetch(Guid userId)
{
using var connection = _factory.CreateConnection()
?? throw new InvalidOperationException("DbProviderFactory returned a null DbConnection.");
connection.ConnectionString = _connectionString;
await connection.OpenAsync();
using var command = connection.CreateCommand();
var userIdParam = command.CreateParameter();
userIdParam.ParameterName = "@UserId";
userIdParam.Value = userId.ToString();
command.CommandText = QRY;
command.Parameters.Add(userIdParam);
using var reader = await command.ExecuteReaderAsync();
var todoLists = new Dictionary<Guid, TodoListData>();
while (await reader.ReadAsync())
{
var todoListIdOrd = reader.GetOrdinal("id");
var titleOrd = reader.GetOrdinal("title");
var todoListId = reader.GetGuid(todoListIdOrd);
if (!todoLists.TryGetValue(todoListId, out TodoListData? value))
{
value = new TodoListData(
Id: todoListId,
Title: reader.GetString(titleOrd),
IsLocked: reader.GetBoolean(reader.GetOrdinal("islocked")),
User: new UserData(
Id: reader.GetGuid(reader.GetOrdinal("userid")),
Name: reader.GetString(reader.GetOrdinal("username")),
Email: reader.GetString(reader.GetOrdinal("useremail"))
),
InTrash: reader.GetBoolean(reader.GetOrdinal("intrash")),
Items: []
);
todoLists[todoListId] = value;
}
if (!await reader.IsDBNullAsync(reader.GetOrdinal("itemid")))
{
value.Items.Add(new TodoItemData(
Id: reader.GetGuid(reader.GetOrdinal("itemid")),
Description: reader.GetString(reader.GetOrdinal("description")),
DueDate: reader.GetDateTime(reader.GetOrdinal("duedate")),
IsDone: reader.GetBoolean(reader.GetOrdinal("isdone"))
));
}
}
return todoLists.Values.ToList().AsReadOnly();
}
3. Surface the Endpoint & Wiring
This controller method structures the API endpoint as a static function for minimal API integration, taking a user ID from the route and injecting the use case via dependency injection. It behaves by executing the use case with the input, handling success with an OK response, and relying on global error handling for failures, keeping the endpoint thin and focused on HTTP concerns.
public static async Task<Results<Ok<IReadOnlyCollection<TodoListData>>>, BadRequest>> Invoke(
Guid userId,
[FromServices] IUseCase<SearchAllTodoListsByUserIdInput, Task<IReadOnlyCollection<TodoListData>>> search)
{
var result = await search.Execute(new SearchAllTodoListsByUserIdInput(userId));
return TypedResults.Ok(result);
}
This module configures dependency injection by registering use case interfaces to their implementations as scoped services. Its structure uses a fluent API for adding services, ensuring each request gets fresh instances. It behaves by binding abstractions to concretes, allowing the composition root to resolve dependencies automatically without tight coupling.
return services
.AddScoped<IUseCase<CreateUserInput, Task<Guid>>, CreateUser>()
.AddScoped<IUseCase<CreateTodoListInput, Task<Guid>>, CreateTodoList>()
.AddScoped<IUseCase<AddItemToTodoListInput, Task<Guid>>, AddTodoItemToTodoList>();
4. Update the Routes.cs file in the infrastructure layer
This file maps the Web API endpoints to their respective controllers, ensuring the query is accessible via HTTP. It registers routes for the SearchAllTodoListsByUserId query, handling HTTP methods and paths.
public static class Routes
{
public static void MapTodoListRoutes(this WebApplication app)
{
app.MapGet("/api/todolists/{userId}", SearchAllTodoListsByUserIdController.Invoke)
.WithName("SearchAllTodoListsByUserId")
.WithOpenApi();
// Additional routes for other queries...
}
}
The Routes.cs file centralizes endpoint definitions, making it easy to manage and update API routes without scattering them across controllers.
5. Validate with Tests
This unit test structures the test method with the [Fact] attribute for xUnit, setting up mock dependencies and test data. It behaves by executing the use case with a valid input, asserting that the result contains the expected number of items, and verifying the query's behavior through mocked interactions, ensuring the use case logic works independently of infrastructure.
[Fact]
public async Task Execute_WithExistingUser_ReturnsUserTodoLists()
{
var todoListData = new List<TodoListData>
{
new(Guid.NewGuid(), "Work Tasks", false, userData, false, new List<TodoItemData>())
};
var mockQuery = new MockAllTodoListsByUserIdQuery();
mockQuery.AddTodoListsForUser(userId, todoListData);
var useCase = new SearchAllTodoListsByUserId(mockQuery, new MockLogger<SearchAllTodoListsByUserId>());
var result = await useCase.Execute(new SearchAllTodoListsByUserIdInput(userId));
Assert.Single(result);
}
Try it yourself
Add pagination parameters to IAllTodoListsByUserIdQuery and update the adapter to keep the signature framework-agnostic.
From Todo to Your Own App
Ready to leave Todo-land? Use this checklist to bootstrap a new product—inventory, bookings, or anything else—with the same patterns.
Build-Your-Own-App Checklist
- Name your domain entities and value objects.
- Write ports that describe the integrations you need.
- Sketch one command and one query use case.
- Decide on adapters (SQL, HTTP, files) and keep them thin.
- Set up dependency injection bindings in your composition root.
- Cover core rules with unit tests and smoke-test the API.
Try it yourself
Clone this repo and rename layers to match a completely different domain (e.g., Booking). Could you keep the same boundaries?
Watch Out for These Traps
As you extend Taskly or build a new product, keep an eye on these common mistakes. Each item below maps to a layer-specific discipline.
Domain & Application
- Avoid leaking infrastructure types (like
DbContext) into entities or use cases. - Keep controllers thin—business rules belong inside use cases, not HTTP endpoints.
- Guard every command with validation before invoking the domain.
Infrastructure & Composition
- Don’t couple adapters to each other; interact via ports to stay swappable.
- Ensure configuration (connection strings, API keys) flows through DI, not hard-coded.
- Write unit tests for adapters’ error handling—fail fast when external systems misbehave.
Investigation
A command modifies the state of the application (e.g., creating, updating, deleting data), while a query only reads and returns data without changing anything.
- Command Example:
CreateTodoList.csinTaskly.Application/TodoLists. It creates a new to-do list. - Query Example:
SearchAllTodoListsByUserId.csinTaskly.Application/TodoLists. It retrieves all to-do lists for a user.
The IUseCase interface defines a standard contract for all use cases (both commands and queries). It takes an input and returns an output, making the application's features uniform and predictable. It's a key part of the "Ports & Adapters" pattern, acting as an input port.
- An Entity is an object with a unique identity that persists over time. Its attributes can change, but its identity remains the same. Example:
TodoListorUser. - A Value Object is an immutable object defined by its attributes, not by a unique ID. If you change an attribute, you get a new value object. Example:
DueDate. TwoDueDateobjects with the same date are considered equal.
ITodoListRepository is an output port. It defines the contract for how the application layer can save and retrieve TodoList entities, without knowing the details of the database. It's implemented in the Taskly.Infrastructure project by AdoTodoListRepository.cs, which contains the actual ADO.NET code to talk to the SQL database.
The AbstractAdoRepository class in Taskly.Infrastructure manages the database connection. It receives a DbProviderFactory and a connection string via dependency injection. It has methods like ExecuteReaderAsync and ExecuteNonQueryAsync that create a DbConnection`, open it, execute a command, and then close it. This ensures that connection management is centralized and not duplicated in every repository.
The "Composition Root" is the place in the application where all the different parts are wired together. It's where dependency injection is configured. In this solution, the Taskly.Main project is the composition root. Specifically, files like Program.cs and the Module.cs files in the Modules folder are responsible for registering services (like repositories and use cases) with the DI container.
An Input Port is an interface that defines how an external actor (like the UI or an API call) can trigger a business action. IUseCase is the primary input port. A Driving Adapter (like a controller) calls an input port. An Output Port is an interface that the application use cases use to access external services like databases or message queues. ITodoListRepository is an example of an output port. A Driven Adapter (like AdoTodoListRepository) implements an output port.
The controllers use TypedResults (e.g., TypedResults.Created, TypedResults.Ok, TypedResults.BadRequest`). This provides a strongly-typed way to return HTTP status codes and results, which improves code clarity and allows for better compile-time checking and OpenAPI/Swagger documentation generation. Exception handling middleware in WebApi/Module.cs also catches specific exceptions like ElementNotFoundException and maps them to appropriate HTTP status codes (like 404 Not Found).
AbstractAdoRepository centralizes the logic for interacting with the database using ADO.NET. It handles creating database connections (DbConnection), commands (DbCommand), and parameters (DbParameter). It provides helper methods like ExecuteNonQueryAsync and ExecuteReaderAsync so that concrete repository implementations (AdoUserRepository, AdoTodoListRepository) don't have to repeat this boilerplate code. It's a good example of the Template Method pattern.
API endpoints are registered in Taskly.Infrastructure/WebApi/Routes.cs using extension methods on WebApplication. For example, MapTodoListRoutes defines all routes related to to-do lists (e.g., app.MapPost("/api/todolists", ...)). This approach centralizes route configuration, making it easy to see all available endpoints in one place, rather than scattering them across multiple files using attributes.
These are extension methods that encapsulate the dependency injection (DI) configuration for each layer/module. AddPersistenceModule registers services like IUserRepository and ITodoListRepository. AddWebApiModule sets up API-related services like Swagger. AddUseCases registers all the application use cases. This keeps the main Program.cs file clean and organized, following the "Composition Root" pattern where all dependencies are wired together at the application's entry point.
This is a validation step to ensure that a to-do list is only created for a user that actually exists in the system. The use case is responsible for orchestrating business rules, and one of those rules is that a TodoList must be associated with a valid User. If the user is not found, it throws an ElementNotFoundException, which prevents invalid data from being created.