Service layer patterns including interface-based design, ServiceResult pattern, business logic encapsulation, structured logging, and error handling. Use when creating or reviewing service classes.
Services contain all business logic. They are the heart of the application, orchestrating operations between controllers and data access.
Always define an interface for each service:
public interface ITaskService
{
Task<List<TaskResponse>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaskResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<TaskResponse> CreateAsync(CreateTaskRequest request, CancellationToken cancellationToken = default);
Task<TaskResponse?> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
public class TaskService(ITaskRepository repository, ILogger<TaskService> logger) : ITaskService
{
// Implementation
}
For operations that can fail in expected ways, use a result pattern:
public record ServiceResult<T>
{
public bool IsSuccess { get; init; }
public T? Value { get; init; }
public string? Error { get; init; }
public ServiceErrorType? ErrorType { get; init; }
public static ServiceResult<T> Success(T value) => new() { IsSuccess = true, Value = value };
public static ServiceResult<T> NotFound(string error) => new() { IsSuccess = false, Error = error, ErrorType = ServiceErrorType.NotFound };
public static ServiceResult<T> ValidationError(string error) => new() { IsSuccess = false, Error = error, ErrorType = ServiceErrorType.Validation };
}
public enum ServiceErrorType
{
NotFound,
Validation,
Conflict,
Forbidden
}
// Good - business rules in service
public async Task<TaskResponse> CreateAsync(CreateTaskRequest request, CancellationToken cancellationToken)
{
var task = new TaskItem
{
Id = Guid.NewGuid(),
Title = request.Title.Trim(),
Description = request.Description?.Trim(),
Status = TaskStatus.Pending,
CreatedAt = DateTime.UtcNow
};
// Business rule: Set default due date if not provided
task.DueDate ??= DateTime.UtcNow.AddDays(7);
await repository.CreateAsync(task, cancellationToken);
logger.LogInformation("Created task {TaskId} with title {Title}", task.Id, task.Title);
return MapToResponse(task);
}
Controllers should only:
// Good - structured with named parameters
logger.LogInformation("Created task {TaskId} with title {Title}", task.Id, task.Title);
logger.LogWarning("Task {TaskId} not found for update", id);
logger.LogError(ex, "Failed to delete task {TaskId}", id);
// Bad - string interpolation
logger.LogInformation($"Created task {task.Id} with title {task.Title}");
LogDebug - Detailed info for debuggingLogInformation - General operational eventsLogWarning - Unexpected but handled situationsLogError - Errors that need attention// Don't catch unexpected exceptions in services
public async Task<TaskResponse> CreateAsync(CreateTaskRequest request, CancellationToken cancellationToken)
{
// If repository.CreateAsync throws, let it propagate
// Global exception handler will log and return 500
var task = new TaskItem { /* ... */ };
await repository.CreateAsync(task, cancellationToken);
return MapToResponse(task);
}
public class TaskService(ITaskRepository repository, ILogger<TaskService> logger) : ITaskService
{
// Service methods...
private static TaskResponse MapToResponse(TaskItem task) => new(
task.Id,
task.Title,
task.Description,
task.Status == TaskStatus.Completed,
task.CreatedAt
);
private static List<TaskResponse> MapToResponse(IEnumerable<TaskItem> tasks) =>
tasks.Select(MapToResponse).ToList();
}
Register services in Program.cs:
builder.Services.AddScoped<ITaskService, TaskService>();
builder.Services.AddScoped<ITaskRepository, JsonTaskRepository>();