Arquitetura e Padrões de Desenvolvimento - MeAjudaAi¶
Este documento detalha a arquitetura, padrões de design e diretrizes de desenvolvimento do projeto MeAjudaAi.
🏗️ Visão Geral da Arquitetura¶
Clean Architecture + DDD¶
O MeAjudaAi implementa Clean Architecture combinada com Domain-Driven Design (DDD) para máxima testabilidade e manutenibilidade.
graph TB
subgraph "🌐 Presentation Layer"
API[API Controllers]
MW[Middlewares]
FIL[Filtros]
end
subgraph "📋 Application Layer"
CMD[Commands]
QRY[Queries]
HDL[Handlers]
VAL[Validators]
end
subgraph "🏛️ Domain Layer"
ENT[Entities]
VO[Value Objects]
DOM[Domain Services]
EVT[Domain Events]
end
subgraph "🔧 Infrastructure Layer"
REPO[Repositories]
EXT[External Services]
CACHE[Caching]
MSG[Messaging]
end
API --> HDL
HDL --> DOM
HDL --> REPO
REPO --> ENT
DOM --> ENT
ENT --> VO
Modular Monolith¶
Estrutura modular que facilita futuras extrações para microserviços.
src/
├── Modules/ # Módulos de domínio
│ ├── Users/ # Gestão de usuários
│ ├── Providers/ # Prestadores de serviços
│ ├── Services/ # Catálogo de serviços (futuro)
│ ├── Bookings/ # Agendamentos (futuro)
│ └── Payments/ # Pagamentos (futuro)
├── Shared/ # Componentes compartilhados
│ └── MeAjudaAi.Shared/ # Primitivos e abstrações
├── Bootstrapper/ # Configuração e startup
│ └── MeAjudaAi.ApiService/ # API principal
└── Aspire/ # Orquestração de desenvolvimento
├── MeAjudaAi.AppHost/ # Host Aspire
└── MeAjudaAi.ServiceDefaults/ # Configurações padrão
🎨 Design Patterns Implementados¶
Este projeto implementa diversos padrões de design consolidados para garantir manutenibilidade, testabilidade e escalabilidade.
1. Repository Pattern¶
Propósito: Abstrair acesso a dados, permitindo testes unitários e troca de implementação.
Implementação Real:
// Interface do repositório (Domain Layer)
public interface IAllowedCityRepository
{
Task<AllowedCity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<AllowedCity?> GetByCityAndStateAsync(string cityName, string stateSigla, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(string cityName, string stateSigla, CancellationToken cancellationToken = default);
Task<bool> IsCityAllowedAsync(string cityName, string stateSigla, CancellationToken cancellationToken = default);
Task AddAsync(AllowedCity allowedCity, CancellationToken cancellationToken = default);
Task UpdateAsync(AllowedCity allowedCity, CancellationToken cancellationToken = default);
Task DeleteAsync(AllowedCity allowedCity, CancellationToken cancellationToken = default);
}
// Implementação EF Core (Infrastructure Layer)
internal sealed class AllowedCityRepository(LocationsDbContext context) : IAllowedCityRepository
{
public async Task<AllowedCity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await context.AllowedCities
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task<bool> IsCityAllowedAsync(string cityName, string stateSigla, CancellationToken cancellationToken = default)
{
var normalizedCity = cityName?.Trim() ?? string.Empty;
var normalizedState = stateSigla?.Trim().ToUpperInvariant() ?? string.Empty;
return await context.AllowedCities
.AnyAsync(x =>
EF.Functions.ILike(x.CityName, normalizedCity) &&
x.StateSigla == normalizedState &&
x.IsActive,
cancellationToken);
}
}
Benefícios: - ✅ Testes unitários sem banco de dados (mocks) - ✅ Encapsulamento de queries complexas - ✅ Possibilidade de cache transparente
2. CQRS (Command Query Responsibility Segregation)¶
Propósito: Separar operações de leitura (queries) das de escrita (commands).
Implementação Real - Command:
// Command (Application Layer)
public sealed record CreateAllowedCityCommand(
string CityName,
string StateSigla,
string? IbgeCode = null
) : ICommand<Result<Guid>>;
// Handler (Application Layer)
internal sealed class CreateAllowedCityCommandHandler(
IAllowedCityRepository repository,
IUnitOfWork unitOfWork,
ILogger<CreateAllowedCityCommandHandler> logger)
: ICommandHandler<CreateAllowedCityCommand, Result<Guid>>
{
public async Task<Result<Guid>> HandleAsync(
CreateAllowedCityCommand command,
CancellationToken cancellationToken)
{
// 1. Validar duplicação
if (await repository.ExistsAsync(command.CityName, command.StateSigla, cancellationToken))
{
return Result<Guid>.Failure(LocationsErrors.CityAlreadyExists(command.CityName, command.StateSigla));
}
// 2. Criar entidade de domínio
var allowedCity = AllowedCity.Create(
command.CityName,
command.StateSigla,
command.IbgeCode
);
// 3. Persistir
await repository.AddAsync(allowedCity, cancellationToken);
await unitOfWork.CommitAsync(cancellationToken);
logger.LogInformation("Cidade permitida criada: {CityName}/{State}", command.CityName, command.StateSigla);
return Result<Guid>.Success(allowedCity.Id);
}
}
Implementação Real - Query:
// Query (Application Layer)
public sealed record GetServiceCategoryByIdQuery(Guid CategoryId) : IQuery<Result<ServiceCategoryDto?>>;
// Handler (Application Layer)
internal sealed class GetServiceCategoryByIdQueryHandler(
IServiceCategoryRepository repository)
: IQueryHandler<GetServiceCategoryByIdQuery, Result<ServiceCategoryDto?>>
{
public async Task<Result<ServiceCategoryDto?>> HandleAsync(
GetServiceCategoryByIdQuery query,
CancellationToken cancellationToken)
{
var category = await repository.GetByIdAsync(query.CategoryId, cancellationToken);
if (category is null)
{
return Result<ServiceCategoryDto?>.Success(null);
}
var dto = ServiceCategoryMapper.ToDto(category);
return Result<ServiceCategoryDto?>.Success(dto);
}
}
Benefícios: - ✅ Separação clara de responsabilidades - ✅ Otimização independente de leitura vs escrita - ✅ Testabilidade individual de cada operação - ✅ Escalabilidade (queries podem usar read replicas)
3. Domain Events¶
Propósito: Comunicação desacoplada entre agregados e módulos.
Implementação Real:
// Evento de Domínio
public sealed record ProviderRegisteredDomainEvent(
Guid ProviderId,
Guid UserId,
string Name,
EProviderType Type
) : IDomainEvent
{
public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
}
// Handler do Evento (Infrastructure Layer)
internal sealed class ProviderRegisteredDomainEventHandler(
IMessageBus messageBus,
ILogger<ProviderRegisteredDomainEventHandler> logger)
: IDomainEventHandler<ProviderRegisteredDomainEvent>
{
public async Task Handle(ProviderRegisteredDomainEvent notification, CancellationToken cancellationToken)
{
try
{
// Publicar evento de integração para outros módulos
var integrationEvent = new ProviderRegisteredIntegrationEvent(
notification.ProviderId,
notification.UserId,
notification.Name,
notification.Type.ToString()
);
await messageBus.PublishAsync(integrationEvent, cancellationToken);
logger.LogInformation(
"Evento de integração publicado para Provider {ProviderId}",
notification.ProviderId);
}
catch (Exception ex)
{
logger.LogError(ex, "Erro ao processar evento ProviderRegisteredDomainEvent");
throw;
}
}
}
// Uso no Agregado
public class Provider : AggregateRoot<Guid>
{
public static Provider Create(Guid userId, string name, EProviderType type, /* ... */)
{
var provider = new Provider
{
Id = UuidGenerator.NewId(),
UserId = userId,
Name = name,
Type = type,
// ...
};
// Adicionar evento de domínio
provider.AddDomainEvent(new ProviderRegisteredDomainEvent(
provider.Id,
userId,
name,
type
));
return provider;
}
}
Benefícios: - ✅ Desacoplamento entre agregados - ✅ Auditoria automática de mudanças - ✅ Integração assíncrona entre módulos - ✅ Extensibilidade (novos handlers sem alterar código existente)
4. Unit of Work Pattern¶
Propósito: Coordenar mudanças em múltiplos repositórios com transações.
Implementação Real:
// Interface (Shared Layer)
public interface IUnitOfWork
{
Task<int> CommitAsync(CancellationToken cancellationToken = default);
Task RollbackAsync(CancellationToken cancellationToken = default);
}
// Implementação EF Core (Infrastructure Layer)
internal sealed class UnitOfWork(DbContext context) : IUnitOfWork
{
public async Task<int> CommitAsync(CancellationToken cancellationToken = default)
{
// EF Core já gerencia transação implicitamente
return await context.SaveChangesAsync(cancellationToken);
}
public async Task RollbackAsync(CancellationToken cancellationToken = default)
{
await context.Database.RollbackTransactionAsync(cancellationToken);
}
}
// Uso em Handler
internal sealed class UpdateProviderProfileCommandHandler(
IProviderRepository providerRepository,
IDocumentsModuleApi documentsApi,
IUnitOfWork unitOfWork)
{
public async Task<Result> HandleAsync(UpdateProviderProfileCommand command, CancellationToken ct)
{
// 1. Buscar provider
var provider = await providerRepository.GetByIdAsync(command.ProviderId, ct);
// 2. Atualizar aggregate
provider.UpdateProfile(/* ... */);
// 3. Atualizar no repositório
await providerRepository.UpdateAsync(provider, ct);
// 4. Commit atômico (transação)
await unitOfWork.CommitAsync(ct);
return Result.Success();
}
}
Benefícios: - ✅ Transações atômicas - ✅ Coordenação de múltiplas mudanças - ✅ Rollback automático em caso de erro
5. Factory Pattern¶
Propósito: Encapsular lógica de criação de objetos complexos.
Implementação Real:
// UuidGenerator Factory (Shared/Time)
public static class UuidGenerator
{
public static Guid NewId()
{
return Guid.CreateVersion7(); // UUID v7 com timestamp ordenável
}
}
// SerilogConfigurator Factory (Shared/Logging)
public static class SerilogConfigurator
{
public static ILogger CreateLogger(IConfiguration configuration, string environmentName)
{
var loggerConfig = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.WithProperty("Application", "MeAjudaAi")
.Enrich.WithProperty("Environment", environmentName)
.Enrich.WithMachineName()
.Enrich.WithThreadId();
if (environmentName == "Development")
{
loggerConfig.WriteTo.Console();
}
loggerConfig.WriteTo.File(
"logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7
);
return loggerConfig.CreateLogger();
}
}
Benefícios: - ✅ Encapsulamento de lógica de criação - ✅ Configuração centralizada - ✅ Fácil substituição de implementação
6. Strategy Pattern¶
Propósito: Selecionar algoritmo/implementação em runtime.
Implementação Real (MessageBus):
// Interface comum (Shared/Messaging)
public interface IMessageBus
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken = default);
Task SubscribeAsync<T>(Func<T, CancellationToken, Task> handler, CancellationToken cancellationToken = default);
}
// Estratégia 1: RabbitMQ
public class RabbitMqMessageBus : IMessageBus
{
public async Task PublishAsync<T>(T message, CancellationToken ct)
{
// Implementação RabbitMQ
}
}
// Estratégia 2: Azure Service Bus
public class ServiceBusMessageBus : IMessageBus
{
public async Task PublishAsync<T>(T message, CancellationToken ct)
{
// Implementação Azure Service Bus
}
}
// Seleção em runtime (Program.cs)
var messageBusProvider = builder.Configuration["MessageBus:Provider"];
if (messageBusProvider == "ServiceBus")
{
builder.Services.AddSingleton<IMessageBus, ServiceBusMessageBus>();
}
else
{
builder.Services.AddSingleton<IMessageBus, RabbitMqMessageBus>();
}
Benefícios: - ✅ Troca de implementação sem alterar código cliente - ✅ Suporte a múltiplos providers (RabbitMQ, Azure, Kafka) - ✅ Testabilidade (mocks)
7. Decorator Pattern (via Pipeline Behaviors)¶
Propósito: Adicionar comportamentos cross-cutting (logging, validação, cache) transparentemente.
Implementação Real:
// Behavior para Caching (Shared/Behaviors)
public class CachingBehavior<TRequest, TResponse>(
ICacheService cacheService,
ILogger<CachingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Só aplica cache se query implementa ICacheableQuery
if (request is not ICacheableQuery cacheableQuery)
{
return await next();
}
var cacheKey = cacheableQuery.GetCacheKey();
var cacheExpiration = cacheableQuery.GetCacheExpiration();
// Tentar buscar no cache
var (cachedResult, isCached) = await cacheService.GetAsync<TResponse>(cacheKey, cancellationToken);
if (isCached)
{
logger.LogDebug("Cache hit for key: {CacheKey}", cacheKey);
return cachedResult;
}
// Executar query e cachear resultado
var result = await next();
if (result is not null)
{
await cacheService.SetAsync(cacheKey, result, cacheExpiration, cancellationToken);
}
return result;
}
}
// Registro (Application Layer Extensions)
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
Benefícios: - ✅ Concerns cross-cutting sem poluir handlers - ✅ Ordem de execução configurável - ✅ Adição/remoção de behaviors sem alterar código
8. Options Pattern¶
Propósito: Configuração fortemente tipada via injeção de dependência.
Implementação Real:
// Opções fortemente tipadas (Shared/Messaging)
public sealed class MessageBusOptions
{
public const string SectionName = "MessageBus";
public string Provider { get; set; } = "RabbitMQ"; // ou "ServiceBus"
public string ConnectionString { get; set; } = string.Empty;
public int RetryCount { get; set; } = 3;
public int RetryDelaySeconds { get; set; } = 5;
}
// Registro no Program.cs
builder.Services.Configure<MessageBusOptions>(
builder.Configuration.GetSection(MessageBusOptions.SectionName));
// Uso via injeção
public class RabbitMqMessageBus(
IOptions<MessageBusOptions> options,
ILogger<RabbitMqMessageBus> logger)
{
private readonly MessageBusOptions _options = options.Value;
public async Task PublishAsync<T>(T message, CancellationToken ct)
{
// Usa _options.ConnectionString, _options.RetryCount, etc.
}
}
Benefícios: - ✅ Configuração fortemente tipada (compile-time safety) - ✅ Validação via Data Annotations - ✅ Hot reload de configurações (IOptionsSnapshot)
9. Middleware Pipeline Pattern¶
Propósito: Processar requisições HTTP em cadeia com responsabilidades isoladas.
Implementação Real:
// Middleware customizado (ApiService/Middlewares)
public class GeographicRestrictionMiddleware(
RequestDelegate next,
ILocationsModuleApi locationsApi,
ILogger<GeographicRestrictionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
// 1. Verificar se endpoint requer restrição geográfica
var endpoint = context.GetEndpoint();
var restrictionAttribute = endpoint?.Metadata
.GetMetadata<RequireGeographicRestrictionAttribute>();
if (restrictionAttribute is null)
{
await next(context);
return;
}
// 2. Extrair cidade/estado da requisição
var city = context.Request.Headers["X-City"].ToString();
var state = context.Request.Headers["X-State"].ToString();
if (string.IsNullOrEmpty(city) || string.IsNullOrEmpty(state))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new { error = "Cidade e estado são obrigatórios" });
return;
}
// 3. Validar via LocationsModuleApi
var isAllowed = await locationsApi.IsCityAllowedAsync(city, state);
if (!isAllowed.IsSuccess || !isAllowed.Value)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new { error = "Cidade não atendida" });
return;
}
// 4. Continuar pipeline
await next(context);
}
}
// Registro no pipeline (Program.cs)
app.UseMiddleware<GeographicRestrictionMiddleware>();
Benefícios: - ✅ Separação de concerns (logging, auth, validação) - ✅ Ordem de execução clara - ✅ Reutilização entre endpoints
🚫 Anti-Patterns Evitados¶
❌ Anemic Domain Model¶
Evitado: Entidades ricas com comportamento encapsulado.
// ❌ ANTI-PATTERN: Anemic Domain
public class Provider
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Status { get; set; } // string sem validação
}
// ✅ PATTERN CORRETO: Rich Domain Model
public class Provider : AggregateRoot<Guid>
{
public string Name { get; private set; }
public EProviderStatus Status { get; private set; }
public void Activate(string adminEmail)
{
if (Status != EProviderStatus.PendingApproval)
throw new InvalidOperationException("Provider must be pending approval");
Status = EProviderStatus.Active;
AddDomainEvent(new ProviderActivatedDomainEvent(Id, adminEmail));
}
}
❌ Repository Anti-Patterns¶
Evitado: Repositórios genéricos com métodos desnecessários.
// ❌ ANTI-PATTERN: Generic Repository com métodos inutilizados
public interface IRepository<T>
{
Task<T> GetByIdAsync(Guid id);
Task<IEnumerable<T>> GetAllAsync(); // Perigoso: pode retornar milhões de registros
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
// ✅ PATTERN CORRETO: Repositórios específicos por agregado
public interface IProviderRepository
{
Task<Provider?> GetByIdAsync(Guid id, CancellationToken ct);
Task<Provider?> GetByUserIdAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<Provider>> GetByCityAsync(string city, int pageSize, int page, CancellationToken ct);
// Apenas métodos realmente necessários
}
❌ Service Locator¶
Evitado: Dependency Injection explícita via construtor.
// ❌ ANTI-PATTERN: Service Locator
public class ProviderService
{
public void RegisterProvider(RegisterProviderDto dto)
{
var repository = ServiceLocator.GetService<IProviderRepository>();
var logger = ServiceLocator.GetService<ILogger>();
// Dependências ocultas, difícil de testar
}
}
// ✅ PATTERN CORRETO: Constructor Injection
public class RegisterProviderCommandHandler(
IProviderRepository repository,
IUnitOfWork unitOfWork,
ILogger<RegisterProviderCommandHandler> logger)
{
// Dependências explícitas e testáveis
}
📚 Referências e Boas Práticas¶
- Clean Architecture: Uncle Bob (Robert C. Martin)
- Domain-Driven Design: Eric Evans, Vaughn Vernon
- CQRS: Greg Young, Udi Dahan
- Modular Monolith: Milan Jovanovic, Kamil Grzybek
- Repository Pattern: Martin Fowler
- .NET Design Patterns: Microsoft Docs
🎯 Domain-Driven Design (DDD)¶
Bounded Contexts¶
1. Users Context¶
Responsabilidade: Gestão completa de identidade e perfis de usuário
namespace MeAjudaAi.Modules.Users.Domain;
/// <summary>
/// Contexto delimitado para gestão de usuários e identidade
/// </summary>
public class UsersContext
{
// Entidades principais
public DbSet<User> Users { get; set; }
// Agregados relacionados
public DbSet<UserProfile> UserProfiles { get; set; }
public DbSet<UserPreferences> UserPreferences { get; set; }
}
Conceitos do Domínio: - User: Agregado raiz para dados básicos de identidade - UserProfile: Perfil detalhado (experiência, habilidades, localização) - UserPreferences: Preferências e configurações personalizadas
2. Providers Context¶
Responsabilidade: Gestão completa de prestadores de serviços
namespace MeAjudaAi.Modules.Providers.Domain;
/// <summary>
/// Contexto delimitado para gestão de prestadores de serviços
/// </summary>
public class ProvidersContext
{
// Entidades principais
public DbSet<Provider> Providers { get; set; }
}
Conceitos do Domínio: - Provider: Agregado raiz para prestadores de serviços com perfil empresarial - BusinessProfile: Perfil empresarial detalhado (razão social, contato, endereço) - Document: Documentos de verificação (CPF, CNPJ, certificações) - Qualification: Qualificações e habilitações profissionais - VerificationStatus: Status de verificação (Pending, Verified, Rejected, etc.)
3. ServiceCatalogs Context (Implementado)¶
Responsabilidade: Catálogo administrativo de categorias e serviços
Conceitos Implementados: - ServiceCategory: Categorias hierárquicas de serviços (aggregate root) - Service: Serviços oferecidos vinculados a categorias (aggregate root) - DisplayOrder: Ordenação customizada para apresentação - Activation/Deactivation: Controle de visibilidade no catálogo
Schema: service_catalogs (isolado no PostgreSQL)
4. Location Context (Implementado)¶
Responsabilidade: Geolocalização e lookup de CEP brasileiro
Conceitos Implementados: - Cep: Value object para CEP validado - Coordinates: Latitude/Longitude para geolocalização - Address: Endereço completo com dados estruturados - CepLookupService: Integração com ViaCEP, BrasilAPI, OpenCEP (fallback)
Observação: Módulo stateless (sem schema próprio), fornece serviços via Module API
5. Bookings Context (Futuro)¶
Responsabilidade: Agendamento e execução de serviços
Conceitos Planejados: - Booking: Agregado raiz para agendamentos - Schedule: Disponibilidade de prestadores - ServiceExecution: Execução e acompanhamento do serviço
Agregados e Entidades¶
Agregado User¶
/// <summary>
/// Agregado raiz para gestão de usuários do sistema
/// Responsável por manter a consistência dos dados do usuário
/// </summary>
public class User : AggregateRoot<UserId>
{
/// <summary>Identificador único externo (Keycloak)</summary>
public ExternalUserId ExternalId { get; private set; }
/// <summary>Email do usuário (único)</summary>
public Email Email { get; private set; }
/// <summary>Nome completo do usuário</summary>
public FullName FullName { get; private set; }
/// <summary>Tipo do usuário no sistema</summary>
public UserType UserType { get; private set; }
/// <summary>Status atual do usuário</summary>
public UserStatus Status { get; private set; }
/// <summary>Perfil detalhado do usuário</summary>
public UserProfile Profile { get; private set; }
/// <summary>Preferências do usuário</summary>
public UserPreferences Preferences { get; private set; }
}
Agregado Provider¶
/// <summary>
/// Agregado raiz para gestão de prestadores de serviços
/// Responsável por manter a consistência dos dados do prestador
/// </summary>
public class Provider : AggregateRoot<ProviderId>
{
/// <summary>Identificador do usuário associado</summary>
public Guid UserId { get; private set; }
/// <summary>Nome do prestador</summary>
public string Name { get; private set; }
/// <summary>Tipo do prestador (Individual ou Company)</summary>
public EProviderType Type { get; private set; }
/// <summary>Perfil empresarial completo</summary>
public BusinessProfile BusinessProfile { get; private set; }
/// <summary>Status de verificação atual</summary>
public EVerificationStatus VerificationStatus { get; private set; }
/// <summary>Documentos de verificação</summary>
public IReadOnlyCollection<Document> Documents { get; }
/// <summary>Qualificações profissionais</summary>
public IReadOnlyCollection<Qualification> Qualifications { get; }
}
Value Objects¶
/// <summary>
/// Value Object para identificador de usuário
/// Garante type safety e validação de identificadores
/// </summary>
public sealed record UserId(Guid Value) : EntityId(Value)
{
public static UserId New() => new(Guid.NewGuid());
public static UserId From(Guid value) => new(value);
public static UserId From(string value) => new(Guid.Parse(value));
}
/// <summary>
/// Value Object para email com validação
/// </summary>
public sealed record Email
{
private static readonly EmailAddressAttribute EmailValidator = new();
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email não pode ser vazio");
if (!EmailValidator.IsValid(value))
throw new ArgumentException($"Email inválido: {value}");
Value = value.ToLowerInvariant();
}
public static implicit operator string(Email email) => email.Value;
public static implicit operator Email(string email) => new(email);
}
Value Objects do Módulo Providers¶
/// <summary>
/// Value Object para identificador de prestador
/// </summary>
public sealed record ProviderId(Guid Value) : EntityId(Value)
{
public static ProviderId New() => new(Guid.NewGuid());
public static ProviderId From(Guid value) => new(value);
}
/// <summary>
/// Value Object para perfil empresarial
/// </summary>
public class BusinessProfile : ValueObject
{
public string LegalName { get; private set; }
public string? FantasyName { get; private set; }
public string? Description { get; private set; }
public ContactInfo ContactInfo { get; private set; }
public Address PrimaryAddress { get; private set; }
public BusinessProfile(
string legalName,
ContactInfo contactInfo,
Address primaryAddress,
string? fantasyName = null,
string? description = null)
{
// Validações e inicialização
}
}
/// <summary>
/// Value Object para documentos
/// </summary>
public class Document : ValueObject
{
public string Number { get; private set; }
public EDocumentType DocumentType { get; private set; }
public Document(string number, EDocumentType documentType)
{
// Validações e inicialização
}
}
Domain Events¶
/// <summary>
/// Evento disparado quando um novo usuário é registrado
/// </summary>
public sealed record UserRegisteredDomainEvent(
UserId UserId,
Email Email,
UserType UserType,
DateTime OccurredAt
) : DomainEvent(OccurredAt);
/// <summary>
/// Evento disparado quando perfil do usuário é atualizado
/// </summary>
public sealed record UserProfileUpdatedDomainEvent(
UserId UserId,
UserProfile UpdatedProfile,
DateTime OccurredAt
) : DomainEvent(OccurredAt);
Domain Events do Módulo Providers¶
/// <summary>
/// Evento disparado quando um novo prestador é registrado
/// </summary>
public sealed record ProviderRegisteredDomainEvent(
Guid AggregateId,
int Version,
Guid UserId,
string Name,
EProviderType Type,
string Email
) : DomainEvent(AggregateId, Version);
/// <summary>
/// Evento disparado quando um documento é adicionado
/// </summary>
public sealed record ProviderDocumentAddedDomainEvent(
Guid AggregateId,
int Version,
string DocumentNumber,
EDocumentType DocumentType
) : DomainEvent(AggregateId, Version);
/// <summary>
/// Evento disparado quando o status de verificação é atualizado
/// </summary>
public sealed record ProviderVerificationStatusUpdatedDomainEvent(
Guid AggregateId,
int Version,
EVerificationStatus OldStatus,
EVerificationStatus NewStatus,
string? UpdatedBy
) : DomainEvent(AggregateId, Version);
/// <summary>
/// Evento disparado quando um prestador é excluído
/// </summary>
public sealed record ProviderDeletedDomainEvent(
Guid AggregateId,
int Version,
string Reason
) : DomainEvent(AggregateId, Version);
⚡ CQRS (Command Query Responsibility Segregation)¶
Estrutura de Commands¶
/// <summary>
/// Command para registro de novo usuário
/// </summary>
public sealed record RegisterUserCommand(
string ExternalId,
string Email,
string FirstName,
string LastName,
UserType UserType
) : ICommand<RegisterUserResult>;
/// <summary>
/// Handler para processamento do command RegisterUser
/// </summary>
public sealed class RegisterUserCommandHandler
: ICommandHandler<RegisterUserCommand, RegisterUserResult>
{
private readonly IUsersRepository _usersRepository;
private readonly IUserProfileService _profileService;
private readonly IEventBus _eventBus;
public async Task<RegisterUserResult> Handle(
RegisterUserCommand command,
CancellationToken cancellationToken)
{
// 1. Validar se usuário já existe
var existingUser = await _usersRepository
.GetByExternalIdAsync(command.ExternalId, cancellationToken);
if (existingUser is not null)
return RegisterUserResult.UserAlreadyExists(command.ExternalId);
// 2. Criar agregado User
var user = User.Create(
ExternalUserId.From(command.ExternalId),
new Email(command.Email),
new FullName(command.FirstName, command.LastName),
command.UserType
);
// 3. Criar perfil inicial
await _profileService.CreateInitialProfileAsync(user.Id, cancellationToken);
// 4. Persistir
await _usersRepository.AddAsync(user, cancellationToken);
// 5. Publicar eventos de domínio
await _eventBus.PublishAsync(user.DomainEvents, cancellationToken);
return RegisterUserResult.Success(user.Id);
}
}
Estrutura de Queries¶
/// <summary>
/// Query para buscar usuário por ID
/// </summary>
public sealed record GetUserByIdQuery(UserId UserId) : IQuery<UserDto?>;
/// <summary>
/// Handler para query GetUserById
/// </summary>
public sealed class GetUserByIdQueryHandler
: IQueryHandler<GetUserByIdQuery, UserDto?>
{
private readonly IUsersReadRepository _repository;
public async Task<UserDto?> Handle(
GetUserByIdQuery query,
CancellationToken cancellationToken)
{
return await _repository.GetUserByIdAsync(query.UserId, cancellationToken);
}
}
DTOs e Mapeamento¶
/// <summary>
/// DTO para transferência de dados de usuário
/// </summary>
public sealed record UserDto(
string Id,
string ExternalId,
string Email,
string FirstName,
string LastName,
string UserType,
string Status,
UserProfileDto? Profile,
DateTime CreatedAt,
DateTime? UpdatedAt
);
/// <summary>
/// Mapper para conversão entre entidades e DTOs
/// </summary>
public static class UserMapper
{
public static UserDto ToDto(User user)
{
return new UserDto(
Id: user.Id.Value.ToString(),
ExternalId: user.ExternalId.Value,
Email: user.Email.Value,
FirstName: user.FullName.FirstName,
LastName: user.FullName.LastName,
UserType: user.UserType.ToString(),
Status: user.Status.ToString(),
Profile: user.Profile?.ToDto(),
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt
);
}
}
🔌 Dependency Injection e Modularização¶
Registro de Serviços por Módulo¶
/// <summary>
/// Extensão para registro dos serviços do módulo Users
/// </summary>
public static class UsersModuleServiceCollectionExtensions
{
public static IServiceCollection AddUsersModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Contexto de banco
services.AddDbContext<UsersDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Users")));
// Repositórios
services.AddScoped<IUsersRepository, UsersRepository>();
services.AddScoped<IUsersReadRepository, UsersReadRepository>();
// Serviços de domínio
services.AddScoped<IUserProfileService, UserProfileService>();
services.AddScoped<IUserValidationService, UserValidationService>();
// Handlers CQRS (registrados via Scrutor em cada módulo)
// Consulte ModuleExtensions.AddApplicationModule() para detalhes
// Validators
services.AddValidatorsFromAssembly(typeof(RegisterUserCommandValidator).Assembly);
// Event Handlers
services.AddScoped<INotificationHandler<UserRegisteredDomainEvent>,
SendWelcomeEmailHandler>();
return services;
}
}
Configuração no Program.cs¶
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Service Defaults (Aspire)
builder.AddServiceDefaults();
// Módulos de domínio
builder.Services.AddUsersModule(builder.Configuration);
// builder.Services.AddServicesModule(builder.Configuration); // Futuro
// builder.Services.AddBookingsModule(builder.Configuration); // Futuro
// Shared services
builder.Services.AddSharedServices(builder.Configuration);
// Infrastructure
builder.Services.AddInfrastructure(builder.Configuration);
var app = builder.Build();
// Middleware pipeline
app.UseSharedMiddleware();
app.MapUsersEndpoints();
app.MapDefaultEndpoints();
app.Run();
}
}
📡 Event-Driven Architecture¶
Domain Events¶
/// <summary>
/// Classe base para eventos de domínio
/// </summary>
public abstract record DomainEvent(DateTime OccurredAt) : IDomainEvent;
/// <summary>
/// Interface para eventos de domínio
/// </summary>
public interface IDomainEvent : INotification
{
DateTime OccurredAt { get; }
}
/// <summary>
/// Agregado base com suporte a eventos de domínio
/// </summary>
public abstract class AggregateRoot<TId> : Entity<TId> where TId : EntityId
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
Implementação do Event Bus¶
/// <summary>
/// Event Bus para publicação de eventos
/// </summary>
public interface IEventBus
{
Task PublishAsync<T>(T @event, CancellationToken cancellationToken = default)
where T : IDomainEvent;
Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementação do Event Bus usando sistema próprio de eventos
/// </summary>
public sealed class DomainEventBus : IEventBus
{
private readonly IServiceProvider _serviceProvider;
public DomainEventBus(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task PublishAsync<T>(T @event, CancellationToken cancellationToken = default)
where T : IDomainEvent
{
await _mediator.Publish(@event, cancellationToken);
}
public async Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken cancellationToken = default)
{
foreach (var @event in events)
{
await _mediator.Publish(@event, cancellationToken);
}
}
}
Event Handlers¶
/// <summary>
/// Handler para evento de usuário registrado
/// </summary>
public sealed class SendWelcomeEmailHandler
: INotificationHandler<UserRegisteredDomainEvent>
{
private readonly IEmailService _emailService;
private readonly ILogger<SendWelcomeEmailHandler> _logger;
public async Task Handle(
UserRegisteredDomainEvent notification,
CancellationToken cancellationToken)
{
try
{
var welcomeEmail = new WelcomeEmail(
To: notification.Email,
UserType: notification.UserType
);
await _emailService.SendAsync(welcomeEmail, cancellationToken);
_logger.LogInformation(
"Email de boas-vindas enviado para {Email} (UserId: {UserId})",
notification.Email, notification.UserId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Erro ao enviar email de boas-vindas para {Email} (UserId: {UserId})",
notification.Email, notification.UserId);
}
}
}
🛡️ Padrões de Segurança¶
Autenticação e Autorização¶
/// <summary>
/// Serviço de autenticação integrado com Keycloak
/// </summary>
public interface IAuthenticationService
{
Task<AuthenticationResult> AuthenticateAsync(string token, CancellationToken cancellationToken = default);
Task<UserContext> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<bool> HasPermissionAsync(string permission, CancellationToken cancellationToken = default);
}
/// <summary>
/// Contexto do usuário atual autenticado
/// </summary>
public sealed record UserContext(
string ExternalId,
string Email,
IReadOnlyList<string> Roles,
IReadOnlyList<string> Permissions
);
/// <summary>
/// Filtro de autorização customizado
/// </summary>
public sealed class RequirePermissionAttribute : AuthorizeAttribute, IAuthorizationRequirement
{
public string Permission { get; }
public RequirePermissionAttribute(string permission)
{
Permission = permission;
Policy = $"RequirePermission:{permission}";
}
}
Validation Pattern¶
/// <summary>
/// Validator para command de registro de usuário
/// </summary>
public sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterUserCommandValidator()
{
RuleFor(x => x.ExternalId)
.NotEmpty()
.WithMessage("ExternalId é obrigatório");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.WithMessage("Email deve ser válido");
RuleFor(x => x.FirstName)
.NotEmpty()
.MaximumLength(100)
.WithMessage("Nome deve ter entre 1 e 100 caracteres");
RuleFor(x => x.LastName)
.NotEmpty()
.MaximumLength(100)
.WithMessage("Sobrenome deve ter entre 1 e 100 caracteres");
RuleFor(x => x.UserType)
.IsInEnum()
.WithMessage("Tipo de usuário inválido");
}
}
🔄 Padrões de Resilência¶
Retry Pattern¶
/// <summary>
/// Política de retry para operações críticas
/// </summary>
public static class RetryPolicies
{
public static readonly RetryPolicy DatabaseRetryPolicy = Policy
.Handle<PostgresException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryCount, context) =>
{
var logger = context.GetLogger();
logger?.LogWarning(
"Tentativa {RetryCount} falhou. Tentando novamente em {Delay}ms",
retryCount, timespan.TotalMilliseconds);
});
public static readonly RetryPolicy ExternalServiceRetryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 2,
sleepDurationProvider: _ => TimeSpan.FromMilliseconds(500));
}
Circuit Breaker Pattern¶
/// <summary>
/// Circuit Breaker para serviços externos
/// </summary>
public static class CircuitBreakerPolicies
{
public static readonly CircuitBreakerPolicy ExternalServiceCircuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (exception, duration) =>
{
// Log circuit breaker opened
},
onReset: () =>
{
// Log circuit breaker closed
});
}
📊 Observabilidade e Monitoramento¶
Logging Structure¶
/// <summary>
/// Logger estruturado para operações de usuário
/// </summary>
public static partial class UserLogMessages
{
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "User {UserId} registered successfully (Email: {Email}, Type: {UserType})")]
public static partial void UserRegistered(
this ILogger logger, string userId, string email, string userType);
[LoggerMessage(
EventId = 1002,
Level = LogLevel.Warning,
Message = "Duplicate user registration attempt (ExternalId: {ExternalId})")]
public static partial void DuplicateUserRegistration(
this ILogger logger, string externalId);
[LoggerMessage(
EventId = 1003,
Level = LogLevel.Error,
Message = "User registration failed (ExternalId: {ExternalId})")]
public static partial void UserRegistrationFailed(
this ILogger logger, string externalId, Exception exception);
}
Métricas Personalizadas¶
/// <summary>
/// Métricas customizadas para o módulo Users
/// </summary>
public sealed class UserMetrics
{
private readonly Counter<int> _userRegistrationsCounter;
private readonly Histogram<double> _registrationDuration;
private readonly ObservableGauge<int> _activeUsersGauge;
public UserMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("MeAjudaAi.Users");
_userRegistrationsCounter = meter.CreateCounter<int>(
"user_registrations_total",
description: "Total de registros de usuários");
_registrationDuration = meter.CreateHistogram<double>(
"user_registration_duration_ms",
description: "Duração do processo de registro de usuário");
_activeUsersGauge = meter.CreateObservableGauge<int>(
"active_users_total",
description: "Número atual de usuários ativos");
}
public void RecordUserRegistration(UserType userType, double durationMs)
{
_userRegistrationsCounter.Add(1,
new KeyValuePair<string, object?>("user_type", userType.ToString()));
_registrationDuration.Record(durationMs,
new KeyValuePair<string, object?>("user_type", userType.ToString()));
}
}
🧪 Padrões de Teste¶
Test Structure¶
/// <summary>
/// Classe base para testes de unidade do domínio
/// </summary>
public abstract class DomainTestBase
{
protected static User CreateValidUser(
string externalId = "test-external-id",
string email = "test@example.com",
UserType userType = UserType.Customer)
{
return User.Create(
ExternalUserId.From(externalId),
new Email(email),
new FullName("Test", "User"),
userType
);
}
}
/// <summary>
/// Testes para o agregado User
/// </summary>
public sealed class UserTests : DomainTestBase
{
[Fact]
public void Create_ValidData_ShouldCreateUser()
{
// Arrange
var externalId = ExternalUserId.From("test-id");
var email = new Email("test@example.com");
var fullName = new FullName("Test", "User");
var userType = UserType.Customer;
// Act
var user = User.Create(externalId, email, fullName, userType);
// Assert
user.Should().NotBeNull();
user.ExternalId.Should().Be(externalId);
user.Email.Should().Be(email);
user.FullName.Should().Be(fullName);
user.UserType.Should().Be(userType);
user.Status.Should().Be(UserStatus.Active);
user.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<UserRegisteredDomainEvent>();
}
}
Integration Tests¶
/// <summary>
/// Classe base para testes de integração
/// </summary>
public abstract class IntegrationTestBase : IClassFixture<TestWebApplicationFactory>
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient Client;
protected readonly IServiceScope Scope;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
Client = factory.CreateClient();
Scope = factory.Services.CreateScope();
}
protected T GetService<T>() where T : notnull
=> Scope.ServiceProvider.GetRequiredService<T>();
}
/// <summary>
/// Testes de integração para endpoints de usuário
/// </summary>
public sealed class UserEndpointsTests : IntegrationTestBase
{
public UserEndpointsTests(TestWebApplicationFactory factory) : base(factory) { }
[Fact]
public async Task RegisterUser_ValidData_ShouldReturnCreated()
{
// Arrange
var request = new RegisterUserRequest(
ExternalId: "test-external-id",
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
UserType: "Customer"
);
// Act
var response = await Client.PostAsJsonAsync("/api/users/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<RegisterUserResponse>();
result.Should().NotBeNull();
result!.UserId.Should().NotBeEmpty();
}
}
Integration Test Infrastructure - Performance Optimization¶
Problema Identificado (Sprint 7.6 - Jan 2026):
Testes de integração aplicavam migrations de TODOS os 6 módulos (Users, Providers, Documents, ServiceCatalogs, Locations, SearchProviders) para CADA teste, causando: - ❌ Timeout frequente (~60-70s de inicialização) - ❌ PostgreSQL pool exhaustion (erro 57P01) - ❌ Testes quebrando sem mudança de código (race condition)
Solução: On-Demand Migrations Pattern
Implementado sistema de flags para aplicar migrations apenas dos módulos necessários:
/// <summary>
/// Enum de flags para especificar quais módulos o teste necessita.
/// Use bitwise OR para combinar múltiplos módulos.
/// </summary>
[Flags]
public enum TestModule
{
None = 0, // Sem migrations (testes de DI/configuração apenas)
Users = 1 << 0, // 1
Providers = 1 << 1, // 2
Documents = 1 << 2, // 4
ServiceCatalogs = 1 << 3, // 8
Locations = 1 << 4, // 16
SearchProviders = 1 << 5, // 32
All = Users | Providers | Documents | ServiceCatalogs | Locations | SearchProviders // 63
}
/// <summary>
/// Classe base otimizada para testes de integração.
/// Override RequiredModules para especificar quais módulos são necessários.
/// </summary>
public abstract class BaseApiTest : IAsyncLifetime
{
/// <summary>
/// Override this property to specify which modules are required for your tests.
/// Default is TestModule.All for backward compatibility.
/// </summary>
protected virtual TestModule RequiredModules => TestModule.All;
public async Task InitializeAsync()
{
// Aplica migrations apenas para módulos especificados
await ApplyRequiredModuleMigrationsAsync(scope.ServiceProvider, logger);
}
private async Task ApplyRequiredModuleMigrationsAsync(
IServiceProvider serviceProvider,
ILogger? logger)
{
var modules = RequiredModules;
if (modules == TestModule.None) return;
// Limpa banco uma única vez
await EnsureCleanDatabaseAsync(anyContext, logger);
// Aplica migrations apenas para módulos requeridos
if (modules.HasFlag(TestModule.Users))
{
var context = serviceProvider.GetRequiredService<UsersDbContext>();
await ApplyMigrationForContextAsync(context, "Users", logger, "UsersDbContext");
await context.Database.CloseConnectionAsync();
}
if (modules.HasFlag(TestModule.Providers))
{
var context = serviceProvider.GetRequiredService<ProvidersDbContext>();
await ApplyMigrationForContextAsync(context, "Providers", logger, "ProvidersDbContext");
await context.Database.CloseConnectionAsync();
}
// ... repeat for each module
}
}
Uso em Test Classes:
/// <summary>
/// Testes de integração do módulo Documents.
/// Otimizado para aplicar apenas migrations do módulo Documents.
/// </summary>
public class DocumentsIntegrationTests : BaseApiTest
{
// Declara apenas os módulos necessários (83% faster)
protected override TestModule RequiredModules => TestModule.Documents;
[Fact]
public void DocumentRepository_ShouldBeRegisteredInDI()
{
using var scope = Services.CreateScope();
var repository = scope.ServiceProvider.GetService<IDocumentRepository>();
repository.Should().NotBeNull();
}
}
/// <summary>
/// Testes cross-module - usa múltiplos módulos.
/// </summary>
public class SearchProvidersApiTests : BaseApiTest
{
// SearchProviders depende de Providers e ServiceCatalogs para denormalização
protected override TestModule RequiredModules =>
TestModule.SearchProviders |
TestModule.Providers |
TestModule.ServiceCatalogs;
[Fact]
public async Task SearchProviders_ShouldReturnDenormalizedData()
{
// Test implementation
}
}
Benefícios da Otimização:
| Cenário | Antes (All Modules) | Depois (Required Only) | Improvement |
|---|---|---|---|
| Inicialização | ~60-70s | ~10-15s | 83% faster |
| Migrations aplicadas | 6 módulos sempre | Apenas necessárias | Mínimo necessário |
| Timeouts | Frequentes | Raros/Eliminados | ✅ Estável |
| Pool de conexões | Esgotamento frequente | Isolado por módulo | ✅ Confiável |
Quando Usar Cada Opção:
TestModule.None: Testes de DI/configuração sem banco de dados- Single Module (ex:
TestModule.Documents): Maioria dos casos - RECOMENDADO - Multiple Modules (ex:
TestModule.Providers | TestModule.ServiceCatalogs): Integração cross-module TestModule.All: Legado/testes E2E completos - EVITAR quando possível
Fluxo de Migrations (Antes vs Depois):
ANTES (Todo teste - 60-70s):
┌─────────────────────────────────────────────────┐
│ BaseApiTest.InitializeAsync() │
├─────────────────────────────────────────────────┤
│ 1. Apply Users migrations (~10s) │
│ 2. Apply Providers migrations (~10s) │
│ 3. Apply Documents migrations (~10s) │
│ 4. Apply ServiceCatalogs migrations (~10s) │
│ 5. Apply Locations migrations (~10s) │
│ 6. Apply SearchProviders migrations ❌ TIMEOUT │
└─────────────────────────────────────────────────┘
DEPOIS (DocumentsIntegrationTests - 10s):
┌─────────────────────────────────────────────────┐
│ BaseApiTest.InitializeAsync() │
├─────────────────────────────────────────────────┤
│ RequiredModules = TestModule.Documents │
│ 1. EnsureCleanDatabaseAsync (~2s) │
│ 2. Apply Documents migrations (~8s) ✅ │
│ └─ CloseConnectionAsync │
└─────────────────────────────────────────────────┘
Documentação Relacionada: - tests/MeAjudaAi.Integration.Tests/README.md - Guia completo de uso - docs/development.md - Best practices para desenvolvimento - Project roadmap
🔌 Module APIs - Comunicação Entre Módulos¶
Padrão Module APIs¶
O padrão Module APIs é usado para comunicação síncrona e type-safe entre módulos. Cada módulo pode expor uma API pública através de uma interface bem definida, permitindo que outros módulos a consumam diretamente, sem acoplamento forte com a implementação interna.
Estrutura Recomendada¶
/// <summary>
/// Interface da API pública do módulo Users
/// Define contratos para comunicação síncrona entre módulos.
/// </summary>
public interface IUsersModuleApi : IModuleApi
{
Task<Result<ModuleUserDto?>> GetUserByIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task<Result<bool>> CheckUserExistsAsync(Guid userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementação da API do módulo Users
/// Localizada em: src/Modules/Users/Application/ModuleApi/
/// </summary>
[ModuleApi("Users", "1.0")]
public sealed class UsersModuleApi : IUsersModuleApi
{
// A implementação utiliza os handlers e serviços internos do módulo Users
// para responder às solicitações, sem expor detalhes da camada de domínio.
}
📡 Integration Events - Comunicação Assíncrona¶
Padrão Integration Events¶
Para comunicação assíncrona e desacoplada, o projeto utiliza o padrão de Integration Events. Um módulo publica um evento em um message bus (como RabbitMQ ou Azure Service Bus) quando um estado importante é alterado. Outros módulos podem se inscrever para receber notificações desses eventos e reagir a eles, sem que o publicador precise conhecê-los.
Este padrão é ideal para: - Notificar outros módulos sobre a criação, atualização ou exclusão de entidades. - Disparar fluxos de trabalho em background. - Manter a consistência eventual entre diferentes Bounded Contexts.
Estrutura Recomendada¶
/// <summary>
/// Define um evento de integração que ocorreu no sistema.
/// Herda de IEvent e adiciona um campo 'Source' para identificar o módulo de origem.
/// </summary>
public interface IIntegrationEvent : IEvent
{
string Source { get; }
}
/// <summary>
/// Exemplo de um evento de integração publicado quando um usuário é registrado.
/// Este evento carrega os dados essenciais para que outros módulos possam reagir.
/// </summary>
public sealed record UserRegisteredIntegrationEvent(
Guid UserId,
string Username,
string Email,
DateTime RegisteredAt
) : IIntegrationEvent;
🚦 Status Atual da Implementação¶
Status: ✅ PARCIALMENTE IMPLEMENTADO (Sprint 1 Dias 3-6, Nov 2025)
Module APIs Implementados:¶
1. IDocumentsModuleApi ✅ COMPLETO¶
Localização: src/Shared/Contracts/Modules/Documents/IDocumentsModuleApi.cs
Implementação: src/Modules/Documents/Application/ModuleApi/DocumentsModuleApi.cs
Métodos (7):
Task<Result<ModuleDocumentDto?>> GetDocumentByIdAsync(Guid documentId, CancellationToken ct);
Task<Result<IReadOnlyList<ModuleDocumentDto>>> GetProviderDocumentsAsync(Guid providerId, CancellationToken ct);
Task<Result<ModuleDocumentStatusDto?>> GetDocumentStatusAsync(Guid documentId, CancellationToken ct);
Task<Result<bool>> HasVerifiedDocumentsAsync(Guid providerId, CancellationToken ct);
Task<Result<bool>> HasRequiredDocumentsAsync(Guid providerId, CancellationToken ct);
Task<Result<bool>> HasPendingDocumentsAsync(Guid providerId, CancellationToken ct);
Task<Result<bool>> HasRejectedDocumentsAsync(Guid providerId, CancellationToken ct);
Usado por:
- ✅ ActivateProviderCommandHandler (Providers) - valida documentos antes de ativação
Exemplo de Uso:
// src/Modules/Providers/Application/Handlers/Commands/ActivateProviderCommandHandler.cs
public sealed class ActivateProviderCommandHandler(
IProviderRepository providerRepository,
IDocumentsModuleApi documentsModuleApi, // ✅ Injetado
ILogger<ActivateProviderCommandHandler> logger
) : ICommandHandler<ActivateProviderCommand, Result>
{
public async Task<Result> HandleAsync(ActivateProviderCommand command, CancellationToken ct)
{
// Validar documentos via Documents module
var hasRequiredResult = await documentsModuleApi.HasRequiredDocumentsAsync(command.ProviderId, ct);
if (!hasRequiredResult.Value)
return Result.Failure("Provider must have all required documents before activation");
var hasVerifiedResult = await documentsModuleApi.HasVerifiedDocumentsAsync(command.ProviderId, ct);
if (!hasVerifiedResult.Value)
return Result.Failure("Provider must have verified documents before activation");
var hasPendingResult = await documentsModuleApi.HasPendingDocumentsAsync(command.ProviderId, ct);
if (hasPendingResult.Value)
return Result.Failure("Provider cannot be activated while documents are pending verification");
var hasRejectedResult = await documentsModuleApi.HasRejectedDocumentsAsync(command.ProviderId, ct);
if (hasRejectedResult.Value)
return Result.Failure("Provider cannot be activated with rejected documents");
// Ativar provider
provider.Activate(command.ActivatedBy);
await providerRepository.UpdateAsync(provider, ct);
return Result.Success();
}
}
2. IServiceCatalogsModuleApi ⏳ STUB IMPLEMENTADO¶
Localização: src/Shared/Contracts/Modules/ServiceCatalogs/IServiceCatalogsModuleApi.cs
Implementação: src/Modules/ServiceCatalogs/Application/ModuleApi/ServiceCatalogsModuleApi.cs
Métodos (3):
Task<Result<ServiceValidationResult>> ValidateServicesAsync(IReadOnlyCollection<Guid> serviceIds, CancellationToken ct);
Task<Result<ServiceInfoDto?>> GetServiceByIdAsync(Guid serviceId, CancellationToken ct);
Task<Result<List<ServiceInfoDto>>> GetServicesByCategoryAsync(Guid categoryId, CancellationToken ct);
Status: Stub implementado, aguarda integração com Provider entity (ProviderServices many-to-many table)
TODO:
- Criar tabela ProviderServices no módulo Providers
- Implementar validação de serviços ao associar provider
3. ISearchProvidersModuleApi ✅ COMPLETO¶
Localização: src/Shared/Contracts/Modules/SearchProviders/ISearchProvidersModuleApi.cs
Implementação: src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs
Métodos (3):
Task<Result<ModulePagedSearchResultDto>> SearchProvidersAsync(
double latitude, double longitude, double radiusInKm, Guid[]? serviceIds,
decimal? minRating, ESubscriptionTier[]? subscriptionTiers,
int pageNumber, int pageSize, CancellationToken ct);
Task<Result> IndexProviderAsync(Guid providerId, CancellationToken ct); // ✅ NOVO (Sprint 1)
Task<Result> RemoveProviderAsync(Guid providerId, CancellationToken ct); // ✅ NOVO (Sprint 1)
Usado por:
- ✅ ProviderVerificationStatusUpdatedDomainEventHandler (Providers) - indexa/remove providers em busca
Exemplo de Uso:
// src/Modules/Providers/Infrastructure/Events/Handlers/ProviderVerificationStatusUpdatedDomainEventHandler.cs
public sealed class ProviderVerificationStatusUpdatedDomainEventHandler(
IMessageBus messageBus,
ProvidersDbContext context,
ISearchModuleApi searchModuleApi, // ✅ Injetado
ILogger<ProviderVerificationStatusUpdatedDomainEventHandler> logger
) : IEventHandler<ProviderVerificationStatusUpdatedDomainEvent>
{
public async Task HandleAsync(ProviderVerificationStatusUpdatedDomainEvent domainEvent, CancellationToken ct)
{
var provider = await context.Providers.FirstOrDefaultAsync(p => p.Id == domainEvent.AggregateId, ct);
// Integração com SearchProviders: indexar quando verificado
if (domainEvent.NewStatus == EVerificationStatus.Verified)
{
var indexResult = await searchModuleApi.IndexProviderAsync(provider.Id.Value, ct);
if (indexResult.IsFailure)
logger.LogError("Failed to index provider {ProviderId}: {Error}",
domainEvent.AggregateId, indexResult.Error);
}
// Remover do índice quando rejeitado/suspenso
else if (domainEvent.NewStatus == EVerificationStatus.Rejected ||
domainEvent.NewStatus == EVerificationStatus.Suspended)
{
var removeResult = await searchModuleApi.RemoveProviderAsync(provider.Id.Value, ct);
if (removeResult.IsFailure)
logger.LogError("Failed to remove provider {ProviderId}: {Error}",
domainEvent.AggregateId, removeResult.Error);
}
// Publicar integration event
var integrationEvent = domainEvent.ToIntegrationEvent(provider.UserId, provider.Name);
await messageBus.PublishAsync(integrationEvent, cancellationToken: ct);
}
}
4. ILocationModuleApi ✅ JÁ EXISTIA¶
Localização: src/Shared/Contracts/Modules/Locations/ILocationModuleApi.cs
Implementação: src/Modules/Locations/Application/ModuleApi/LocationModuleApi.cs
Métodos: GetAddressFromCepAsync, ValidateCepAsync, GeocodeAddressAsync
Status: Pronto para uso, não utilizado ainda (baixa prioridade)
Integration Events Implementados:¶
ProviderVerificationStatusUpdated¶
- Publicado por:
ProviderVerificationStatusUpdatedDomainEventHandler(Providers) - Consumido por: Nenhum módulo ainda (preparado para futura expansão)
- Payload: ProviderId, UserId, Name, OldStatus, NewStatus, UpdatedAt
Padrão de Implementação (Resumo):¶
1. Definir Interface em Shared/Contracts/Modules/[ModuleName]
public interface IDocumentsModuleApi : IModuleApi
{
Task<Result<bool>> HasVerifiedDocumentsAsync(Guid providerId, CancellationToken ct);
}
2. Implementar em Module/Application/ModuleApi¶
[ModuleApi("Documents", "1.0")]
public sealed class DocumentsModuleApi(IQueryDispatcher queryDispatcher) : IDocumentsModuleApi
{
public async Task<Result<bool>> HasVerifiedDocumentsAsync(Guid providerId, CancellationToken ct)
{
var query = new GetProviderDocumentsQuery(providerId);
var result = await queryDispatcher.QueryAsync<GetProviderDocumentsQuery, Result<List<DocumentDto>>>(query, ct);
return Result.Success(result.Value?.Any(d => d.Status == EDocumentStatus.Verified) ?? false);
}
}
3. Registrar em DI (Module/Application/Extensions.cs)¶
4. Injetar e Usar em Outro Módulo¶
public sealed class ActivateProviderCommandHandler(
IDocumentsModuleApi documentsApi) // ✅ Cross-module dependency
{
public async Task<Result> HandleAsync(...)
{
var hasVerified = await documentsApi.HasVerifiedDocumentsAsync(providerId, ct);
if (!hasVerified.Value)
return Result.Failure("Documents not verified");
}
}
Benefícios Alcançados:¶
✅ Type-Safe: Contratos bem definidos em Shared/Contracts
✅ Testável: Fácil mockar IModuleApi em unit tests
✅ Desacoplado: Módulos não conhecem implementação interna de outros
✅ Versionado: Atributo [ModuleApi] permite versionamento
✅ Observável: Logging integrado em todas as operações
✅ Resiliente: Result pattern para error handling consistente
Próximos Passos (Sprint 2):¶
- Implementar full provider data sync (IndexProviderAsync com dados completos)
- Criar IProvidersModuleApi para SearchProviders consumir
- Implementar ProviderServices many-to-many table
- Integrar IServiceCatalogsModuleApi em Provider lifecycle
- Adicionar integration event handlers entre módulos
💡 Exemplos Conceituais de Implementação¶
A seguir, exemplos de como implementar os dois padrões de comunicação.
1. Exemplo de IModuleApi (Comunicação Síncrona)¶
Cenário: Ao criar um novo Provider, o módulo Providers precisa verificar se o UserId associado já existe no módulo Users.
Passos de Implementação:
- Injetar
IUsersModuleApi: NoCreateProviderCommandHandlerdo móduloProviders, injete a interfaceIUsersModuleApi. - Chamar o Método da API: Utilize o método
CheckUserExistsAsyncpara validar a existência do usuário.
Exemplo de Código (Conceitual):
// Local: C:\Code\MeAjudaAi\src\Modules\Providers\Application\Providers\Commands\CreateProvider\CreateProviderCommandHandler.cs
// 1. Injetar a IUsersModuleApi
public class CreateProviderCommandHandler(IUsersModuleApi usersModuleApi, /* outras dependências */)
: IRequestHandler<CreateProviderCommand, Result<ProviderDto>>
{
public async Task<Result<ProviderDto>> Handle(CreateProviderCommand request, CancellationToken cancellationToken)
{
// 2. Chamar a API para verificar se o usuário existe
var userExistsResult = await _usersModuleApi.CheckUserExistsAsync(request.UserId, cancellationToken);
if (userExistsResult.IsFailure || !userExistsResult.Value)
{
return Result.Failure<ProviderDto>(new Error("User.NotFound", "O usuário especificado não existe."));
}
// --- Lógica para criação do provider ---
// ...
}
}
2. Exemplo de IIntegrationEvent (Comunicação Assíncrona)¶
Cenário: Quando um novo usuário se registra, o módulo Users publica um UserRegisteredIntegrationEvent. O módulo Search escuta este evento para indexar o novo usuário em seu sistema de busca.
Passos de Implementação:
A. Publicando o Evento (Módulo Users)
- Injetar
IMessageBus: NoCreateUserCommandHandler, injete o serviço de message bus. - Publicar o Evento: Após criar o usuário com sucesso, publique o evento no barramento.
Exemplo de Código (Publicador):
// Local: C:\Code\MeAjudaAi\src\Modules\Users\Application\Users\Commands\CreateUser\CreateUserCommandHandler.cs
// 1. Injetar o message bus
public class CreateUserCommandHandler(IMessageBus messageBus, /* outras dependências */)
: IRequestHandler<CreateUserCommand, Result<UserDto>>
{
public async Task<Result<UserDto>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
// --- Lógica para criar o usuário ---
var user = new User(/* ... */);
await _userRepository.AddAsync(user, cancellationToken);
// 2. Criar e publicar o evento de integração
var integrationEvent = new UserRegisteredIntegrationEvent(
user.Id.Value,
user.Username.Value,
user.Email.Value,
user.CreatedAt
);
await _messageBus.PublishAsync(integrationEvent, cancellationToken);
return Result.Success(user.ToDto());
}
}
B. Consumindo o Evento (Módulo Search)
- Criar um Event Handler: No módulo
Search, crie uma classe que implementaIEventHandler<UserRegisteredIntegrationEvent>. - Implementar a Lógica: No método
HandleAsync, implemente a lógica para indexar o usuário. - Registrar o Handler: Adicione o handler no contêiner de injeção de dependência do módulo
Search.
Exemplo de Código (Consumidor):
// Local: C:\Code\MeAjudaAi\src\Modules\SearchProviders\Application\EventHandlers\UserRegisteredIntegrationEventHandler.cs
// 1. Criar o handler
public class UserRegisteredIntegrationEventHandler : IEventHandler<UserRegisteredIntegrationEvent>
{
private readonly ISearchIndexer _searchIndexer;
public UserRegisteredIntegrationEventHandler(ISearchIndexer searchIndexer)
{
_searchIndexer = searchIndexer;
}
// 2. Implementar a lógica de tratamento
public async Task HandleAsync(UserRegisteredIntegrationEvent @event, CancellationToken cancellationToken)
{
var userDocument = new SearchableUser
{
Id = @event.UserId,
Username = @event.Username,
Email = @event.Email
};
await _searchIndexer.IndexUserAsync(userDocument, cancellationToken);
}
}
// Local: C:\Code\MeAjudaAi\src\Modules\SearchProviders\Infrastructure\Extensions.cs
public static IServiceCollection AddSearchInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// ... outras configurações
// 3. Registrar o handler
services.AddScoped<IEventHandler<UserRegisteredIntegrationEvent>, UserRegisteredIntegrationEventHandler>();
return services;
}
📡 API Collections e Documentação¶
Estratégia Multi-Formato¶
O projeto utiliza múltiplos formatos de collections para diferentes necessidades:
1. OpenAPI/Swagger (PRINCIPAL)¶
- 🎯 Documentação oficial gerada automaticamente do código
- 🔄 Sempre atualizada com o código fonte
- 🌐 Padrão da indústria para APIs REST
- 📊 UI interativa disponível em
/api-docs
// Endpoints automaticamente documentados
[HttpPost("register")]
[ProducesResponseType<RegisterUserResponse>(201)]
[ProducesResponseType<ApiErrorResponse>(400)]
public async Task<IActionResult> RegisterUser([FromBody] RegisterUserCommand command)
{
// Implementação...
}
2. Bruno Collections (.bru) - DESENVOLVIMENTO¶
- ✅ Controle de versão no Git
- ✅ Leve e eficiente para desenvolvedores
- ✅ Variáveis de ambiente configuráveis
- ✅ Scripts pré/pós-request em JavaScript
# Estrutura Bruno
src/Shared/API.Collections/
├── Common/
│ ├── GlobalVariables.bru
│ ├── StandardHeaders.bru
│ └── EnvironmentVariables.bru
├── Setup/
│ ├── SetupGetKeycloakToken.bru
│ └── HealthCheckAll.bru
└── Modules/
└── Users/
├── CreateUser.bru
├── GetUsers.bru
└── UpdateUser.bru
- 🤝 Compartilhamento fácil com QA, PO, clientes
- 🔄 Geração automática via OpenAPI
- 🧪 Testes automáticos integrados
- 📊 Monitoring e reports nativos
Geração Automática de Collections¶
Comandos Disponíveis¶
# Gerar todas as collections
cd tools/api-collections
./generate-all-collections.sh # Linux/Mac
./generate-all-collections.bat # Windows
# Apenas Postman
npm run generate:postman
# Validar collections
npm run validate
Estrutura de Output¶
src/Shared/API.Collections/Generated/
├── MeAjudaAi-API-Collection.json # Collection principal
├── MeAjudaAi-development-Environment.json # Ambiente desenvolvimento
├── MeAjudaAi-production-Environment.json # Ambiente produção
└── README.md # Instruções de uso
Configurações Avançadas do Swagger¶
Filtros Personalizados¶
Melhorias Implementadas¶
- 🔒 Segurança JWT: Configuração automática de Bearer tokens
- 📊 Schemas Reutilizáveis: Componentes comuns (paginação, erros)
- 🌍 Multi-ambiente: URLs para dev/production
Boas Práticas para Collections¶
✅ RECOMENDADO¶
- Manter OpenAPI como fonte única da verdade
- Bruno para desenvolvimento diário
- Postman para colaboração e testes
- Regenerar collections após mudanças na API
- Versionar Bruno collections no Git
❌ EVITAR¶
- Edição manual de Postman collections geradas
- Duplicação de documentação entre formatos
- Collections desatualizadas sem regeneração
- Hardcoding de URLs nos collections
Workflow Recomendado¶
- Desenvolver API com documentação OpenAPI
- Testar localmente com Bruno collections
- Gerar Postman collections para colaboração
- Compartilhar com equipe via Postman workspace
- Regenerar collections em cada release
Exportação OpenAPI para Clientes REST¶
Comando Único¶
Características:
- ✅ Funciona offline (não precisa rodar aplicação)
- ✅ Health checks incluídos (/health, /health/ready, /health/live)
- ✅ Schemas com exemplos realistas
- ✅ Múltiplos ambientes (dev, production)
- ⚠️ Arquivo não versionado (incluído no .gitignore)
Importar em Clientes de API¶
APIDog: Importar → Do Arquivo → Selecionar arquivo
Postman: Importar → Arquivo → Fazer Envio de Arquivos → Selecionar arquivo
Insomnia: Import/Export → Import Data → Selecionar arquivo
Bruno: Import → OpenAPI → Selecionar arquivo
Thunder Client: Import → OpenAPI → Selecionar arquivo
Monitoramento e Testes¶
Especificação OpenAPI inclui:
- ✅ Health endpoints para monitoramento
- ✅ Schemas de erro padronizados
- ✅ Paginação consistente
- ✅ Exemplos realistas para desenvolvimento
- ✅ Documentação rica com descrições detalhadas
// Health check response example
{
"status": "Saudável",
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0.0",
"environment": "Development",
"checks": {
"database": { "status": "Healthy", "duration": "00:00:00.0123456" },
"cache": { "status": "Healthy", "duration": "00:00:00.0087432" }
}
}
¶
// Health check response example
{
"status": "Saudável",
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0.0",
"environment": "Development",
"checks": {
"database": { "status": "Healthy", "duration": "00:00:00.0123456" },
"cache": { "status": "Healthy", "duration": "00:00:00.0087432" }
}
}
📋 C# Records Standardization¶
Positional Records vs Nominal Records¶
O projeto C# 10+ suporta dois estilos de declaração de records:
1. Positional Records (Construtor Primário) - PADRÃO RECOMENDADO¶
// ✅ RECOMENDADO: Conciso, imutável, parâmetros explícitos
public sealed record AllowedCityDto(
Guid Id,
string CityName,
string StateSigla,
int? IbgeCode,
bool IsActive,
DateTime CreatedAt,
DateTime? UpdatedAt,
string CreatedBy,
string? UpdatedBy);
Características:
- ✅ Sintaxe concisa
- ✅ Todos parâmetros obrigatórios na construção
- ✅ Ideal para DTOs de resposta (dados completos)
- ✅ Pattern matching simplificado
- ✅ with expressions para cópias parciais
Uso com with:
2. Nominal Records (Propriedades Init) - CASOS ESPECÍFICOS¶
// ⚠️ APENAS PARA: Requests com valores padrão, States, Configurations
public record GetUsersRequest
{
public string? SearchTerm { get; init; }
public int PageNumber { get; init; } = 1; // Valor padrão
public int PageSize { get; init; } = 20; // Valor padrão
public bool OnlyActive { get; init; } = true;
}
Características: - ✅ Valores padrão úteis - ✅ Construção parcial (propriedades opcionais) - ✅ Ideal para Request DTOs e configuration objects - ⚠️ Mais verboso que positional
Análise do Projeto Atual¶
Estatísticas (199 records totais):
- 158 Positional Records (79%) - public sealed record Dto(...)
- 41 Nominal Records (21%) - public record Request { ... init; }
Distribuição por Categoria:
| Categoria | Padrão | Quantidade | Justificativa |
|---|---|---|---|
| DTOs de Resposta | Positional | ~100 | Dados completos conhecidos |
| Commands/Queries | Positional | ~50 | Parâmetros explícitos |
| Domain Events | Positional | ~20 | Payload imutável |
| Request DTOs | Nominal | ~15 | Valores padrão (pagination) |
| Fluxor States | Nominal | ~7 | Valores padrão + with |
| Configurations | Nominal | ~4 | Valores padrão de config |
| Integration Events | Positional | ~3 | Payload imutável |
Decisões de Padronização¶
SEMPRE use Positional Records para:¶
-
DTOs de Resposta/Visualização
-
Commands/Queries (CQRS)
-
Domain Events
-
Integration Events
-
Value Objects
Use Nominal Records APENAS para:¶
-
Request DTOs com Valores Padrão
-
Fluxor State (State Management)
-
Configuration Objects
Migration Checklist¶
Categorias que PODEM ser convertidas para Positional (15 records):
✅ Response DTOs:
- ValidateServicesResponse → public sealed record ValidateServicesResponse(...)
- SearchableProviderDto → public sealed record SearchableProviderDto(...)
- LocationDto → public sealed record LocationDto(...)
- SearchResult → public sealed record SearchResult(...)
- ModuleProviderIndexingDto → Converter
- ModuleProviderDto → Converter
- DocumentStatusCountDto → Converter
- ModuleDocumentDto → Converter
- ModuleLocationDto → Converter
- ModulePagedSearchResultDto → Converter
- ModuleSearchableProviderDto → Converter
✅ Model DTOs:
- ModuleProviderBasicDto → Converter
- ModuleDocumentStatusDto → Converter
⚠️ MANTER Nominal (Valores Padrão/Config):
- GeoPoint (config object)
- ExternalResources (config)
- FeatureFlags (config)
- UserDeletedIntegrationEvent (empty by design)
- Todos Request DTOs (CreateUserRequest, UpdateProviderProfileRequest, etc.)
- Todos Fluxor States (ErrorState, ThemeState, ServiceCatalogsState, etc.)
Exemplo de Conversão¶
ANTES (Nominal):
public sealed record SearchableProviderDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string? Description { get; init; }
public LocationDto Location { get; init; } = null!;
public List<Guid> ServiceIds { get; init; } = [];
}
DEPOIS (Positional):
public sealed record SearchableProviderDto(
Guid Id,
string Name,
string? Description,
LocationDto Location,
IReadOnlyList<Guid> ServiceIds);
Benefícios da Conversão: - 🔽 6 linhas → 6 palavras (87% redução) - ✅ Parâmetros obrigatórios (compile-time safety) - ✅ Imutabilidade garantida (IReadOnlyList) - ✅ Pattern matching simplificado
Padrões de Naming e sealed¶
sealed modifier¶
// ✅ SEMPRE use sealed em records (exceto base classes)
public sealed record UserDto(...);
public sealed record CreateUserCommand(...) : ICommand<Result>;
// ❌ NÃO use sealed em:
// 1. Base records para herança (raros)
public record BaseResponse(bool Success, string Message);
public sealed record ErrorResponse(string ErrorCode) : BaseResponse(false, ErrorCode);
// 2. Integration events (podem ser estendidos por consumidores externos)
public record IntegrationEventBase(...);
Benefícios do sealed:
- ✅ Desempenho: JIT optimizations
- ✅ Intenção clara: "Este record não deve ser herdado"
- ✅ Segurança: Evita modificações não intencionadas
Checklist de Code Review¶
Ao revisar PRs, verificar:
- DTOs de resposta usam positional records
- Commands/Queries usam positional records
- Domain/Integration events usam positional records
- Request DTOs com valores padrão usam nominal records
- Fluxor States usam nominal records (com
= [],= 1, etc.) - Records usam
sealed(exceto base classes) - Positional records usam IReadOnlyList/IReadOnlyCollection ao invés de List
- Propriedades nullable bem definidas (
string?vsstring)
🚀 C# 14 Features Utilizados¶
Extension Members¶
O projeto utiliza Extension Members, um novo recurso do C# 14 que permite declarar não apenas métodos de extensão, mas também propriedades de extensão, membros estáticos estendidos e operadores definidos pelo usuário.
Padrão Adotado¶
✅ Use Extension Members para: - Extension methods de domínio que se beneficiam de extension properties - APIs fluentes com propriedades computadas - Tipos que precisam de operadores definidos pelo usuário via extensão
❌ Não use para:
- Extensions de configuração DI (IServiceCollection, IApplicationBuilder) - manter padrão tradicional [FolderName]Extensions.cs
- Código legado que funciona bem com sintaxe tradicional
Implementação Atual¶
EnumExtensions - Migrado para Extension Members:
public static class EnumExtensions
{
extension<TEnum>(string value) where TEnum : struct, Enum
{
public TEnum ToEnum()
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Value cannot be null or whitespace.", nameof(value));
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var result))
return result;
throw new ArgumentException($"Unable to convert '{value}' to enum of type {typeof(TEnum)}.", nameof(value));
}
}
}
// Uso
var status = "Active".ToEnum<EProviderStatus>();
Benefícios Observados: - ✅ 54/54 testes passando (100% compatibilidade) - ✅ Sintaxe mais expressiva - ✅ Melhor documentação via properties
🔧 Configurações e Opções¶
Pattern: IOptions¶
O projeto utiliza o padrão IOptions do ASP.NET Core para configurações fortemente tipadas.
DocumentUploadOptions¶
public class DocumentUploadOptions
{
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10MB
public string[] AllowedContentTypes { get; set; } =
[
"image/jpeg",
"image/png",
"image/jpg",
"application/pdf"
];
}
Registro:
Uso em Handler:
public class UploadDocumentCommandHandler(
IOptions<DocumentUploadOptions> uploadOptions)
{
private readonly DocumentUploadOptions _options = uploadOptions.Value;
public async Task HandleAsync(...)
{
if (command.FileSizeBytes > _options.MaxFileSizeBytes)
throw new ArgumentException($"File too large...");
}
}
Vantagens: - Configuração por ambiente (dev/staging/prod) - Tipagem forte - Validação em tempo de compilação - Facilita testes unitários (mock de IOptions)
🎨 Frontend Architecture (Sprint 6+)¶
Blazor WebAssembly + Fluxor + MudBlazor¶
O Admin Portal utiliza Blazor WASM com padrão Flux/Redux para state management e Material Design UI.
graph TB
subgraph "🌐 Presentation - Blazor WASM"
PAGES[Pages/Razor Components]
LAYOUT[Layout Components]
AUTH[Authentication.razor]
end
subgraph "🔄 State Management - Fluxor"
STATE[States]
ACTIONS[Actions]
REDUCERS[Reducers]
EFFECTS[Effects]
end
subgraph "🔌 API Layer - Refit"
PROVIDERS_API[IProvidersApi]
SERVICES_API[IServiceCatalogsApi]
HTTP[HttpClient + Auth]
end
subgraph "🔐 Authentication - OIDC"
KEYCLOAK[Keycloak OIDC]
TOKEN[Token Manager]
end
PAGES --> ACTIONS
ACTIONS --> REDUCERS
REDUCERS --> STATE
STATE --> PAGES
ACTIONS --> EFFECTS
EFFECTS --> PROVIDERS_API
EFFECTS --> SERVICES_API
PROVIDERS_API --> HTTP
HTTP --> TOKEN
TOKEN --> KEYCLOAK
Stack Tecnológica¶
| Componente | Tecnologia | Versão | Propósito |
|---|---|---|---|
| Framework | Blazor WebAssembly | .NET 10 | SPA client-side |
| UI Library | MudBlazor | 7.21.0 | Material Design components |
| State Management | Fluxor | 6.1.0 | Redux-pattern state |
| HTTP Client | Refit | 9.0.2 | Type-safe API clients |
| Authentication | OIDC | WASM.Authentication | Keycloak integration |
| Testing | bUnit + xUnit | 1.40.0 + v3.2.1 | Component tests |
Fluxor Pattern - State Management¶
Implementação do Padrão Flux/Redux:
// 1. STATE (Immutable)
public record ProvidersState
{
public List<ModuleProviderDto> Providers { get; init; } = [];
public bool IsLoading { get; init; }
public string? ErrorMessage { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 20;
public int TotalItems { get; init; }
}
// 2. ACTIONS (Commands)
public static class ProvidersActions
{
public record LoadProvidersAction;
public record LoadProvidersSuccessAction(List<ModuleProviderDto> Providers, int TotalItems);
public record LoadProvidersFailureAction(string ErrorMessage);
public record GoToPageAction(int PageNumber);
}
// 3. REDUCERS (Pure Functions)
public static class ProvidersReducers
{
[ReducerMethod]
public static ProvidersState OnLoadProviders(ProvidersState state, LoadProvidersAction _) =>
state with { IsLoading = true, ErrorMessage = null };
[ReducerMethod]
public static ProvidersState OnLoadSuccess(ProvidersState state, LoadProvidersSuccessAction action) =>
state with
{
Providers = action.Providers,
TotalItems = action.TotalItems,
IsLoading = false,
ErrorMessage = null
};
[ReducerMethod]
public static ProvidersState OnLoadFailure(ProvidersState state, LoadProvidersFailureAction action) =>
state with { IsLoading = false, ErrorMessage = action.ErrorMessage };
[ReducerMethod]
public static ProvidersState OnGoToPage(ProvidersState state, GoToPageAction action) =>
state with { PageNumber = action.PageNumber };
}
// 4. EFFECTS (Side Effects - API Calls)
public class ProvidersEffects
{
private readonly IProvidersApi _providersApi;
public ProvidersEffects(IProvidersApi providersApi)
{
_providersApi = providersApi;
}
[EffectMethod]
public async Task HandleLoadProviders(LoadProvidersAction _, IDispatcher dispatcher)
{
try
{
var result = await _providersApi.GetProvidersAsync(pageNumber: 1, pageSize: 20);
if (result.IsSuccess && result.Value is not null)
{
dispatcher.Dispatch(new LoadProvidersSuccessAction(
result.Value.Items,
result.Value.TotalItems));
}
else
{
dispatcher.Dispatch(new LoadProvidersFailureAction(
result.Error?.Message ?? "Falha ao carregar fornecedores"));
}
}
catch (Exception ex)
{
dispatcher.Dispatch(new LoadProvidersFailureAction(ex.Message));
}
}
}
Fluxo de Dados Unidirecional: 1. User Interaction → Componente dispara Action 2. Action → Fluxor enfileira ação 3. Reducer → Cria novo State (immutable) 4. Effect (se aplicável) → Chama API externa 5. New State → UI re-renderiza automaticamente
Benefícios do Padrão: - ✅ Previsibilidade: Estado centralizado e immutable - ✅ Testabilidade: Reducers são funções puras - ✅ Debug: Redux DevTools integration - ✅ Time-travel: Estado histórico para debugging
Refit - Type-Safe HTTP Clients (SDK)¶
MeAjudaAi.Client.Contracts é o SDK oficial .NET para consumir a API REST, semelhante ao AWS SDK ou Stripe SDK.
SDKs Disponíveis (Sprint 6-7):
| Módulo | Interface | Funcionalidades | Status |
|---|---|---|---|
| Providers | IProvidersApi | CRUD, verificação, filtros | ✅ Completo |
| Documents | IDocumentsApi | Upload, verificação, status | ✅ Completo |
| ServiceCatalogs | IServiceCatalogsApi | Listagem, categorias | ✅ Completo |
| Locations | ILocationsApi | CRUD AllowedCities | ✅ Completo |
| Users | IUsersApi | (Planejado) | ⏳ Sprint 8+ |
Definição de API Contracts:
public interface IProvidersApi
{
[Get("/api/v1/providers")]
Task<Result<PagedResult<ModuleProviderDto>>> GetProvidersAsync(
[Query] int pageNumber = 1,
[Query] int pageSize = 20,
CancellationToken cancellationToken = default);
[Get("/api/v1/providers/verification-status/{status}")]
Task<Result<List<ModuleProviderDto>>> GetProvidersByVerificationStatusAsync(
string status,
CancellationToken cancellationToken = default);
}
public interface IDocumentsApi
{
[Multipart]
[Post("/api/v1/providers/{providerId}/documents")]
Task<Result<ModuleDocumentDto>> UploadDocumentAsync(
Guid providerId,
[AliasAs("file")] StreamPart file,
[AliasAs("documentType")] string documentType,
CancellationToken cancellationToken = default);
}
public interface ILocationsApi
{
[Get("/api/v1/locations/allowed-cities")]
Task<Result<IReadOnlyList<ModuleAllowedCityDto>>> GetAllAllowedCitiesAsync(
[Query] bool onlyActive = true,
CancellationToken cancellationToken = default);
}
public interface IServiceCatalogsApi
{
[Get("/api/v1/service-catalogs/services")]
Task<Result<IReadOnlyList<ModuleServiceListDto>>> GetAllServicesAsync(
[Query] bool activeOnly = true,
CancellationToken cancellationToken = default);
}
Configuração com Autenticação:
// Program.cs - Registrar todos os SDKs
builder.Services.AddRefitClient<IProvidersApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
builder.Services.AddRefitClient<IDocumentsApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
builder.Services.AddRefitClient<IServiceCatalogsApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
builder.Services.AddRefitClient<ILocationsApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(apiBaseUrl))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
Arquitetura Interna do Refit:
Vantagens: - ✅ Type-safe API calls (compile-time validation) - ✅ Automatic serialization/deserialization - ✅ Integration with HttpClientFactory + Polly - ✅ Authentication header injection via message handler - ✅ 20 linhas de código manual → 2 linhas (interface + atributo) - ✅ Reutilizável entre projetos (Blazor WASM, MAUI, Console)
Documentação Completa: src/Client/MeAjudaAi.Client.Contracts/README.md
MudBlazor - Material Design Components¶
Componentes Principais Utilizados:
@* Layout Principal *@
<MudLayout>
<MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
OnClick="@DrawerToggle"
Color="Color.Inherit" />
<MudSpacer />
<MudIconButton Icon="@(IsDarkMode ? Icons.Material.Filled.DarkMode : Icons.Material.Filled.LightMode)"
OnClick="@ToggleDarkMode"
Color="Color.Inherit" />
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2">
<NavMenu />
</MudDrawer>
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>
@* Data Grid com Paginação *@
<MudDataGrid Items="@State.Value.Providers"
Loading="@State.Value.IsLoading"
Hover="true"
Dense="true">
<Columns>
<PropertyColumn Property="x => x.Name" Title="Nome" />
<PropertyColumn Property="x => x.Email" Title="Email" />
<TemplateColumn Title="Status">
<CellTemplate>
<MudChip Color="@GetStatusColor(context.Item.VerificationStatus)">
@context.Item.VerificationStatus
</MudChip>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<MudPagination Count="@TotalPages"
Selected="@State.Value.PageNumber"
SelectedChanged="@OnPageChanged" />
@* KPI Cards *@
<MudCard>
<MudCardHeader>
<CardHeaderAvatar>
<MudIcon Icon="@Icons.Material.Filled.People" Color="Color.Primary" />
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.h6">Total de Fornecedores</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.h3">@State.Value.TotalProviders</MudText>
</MudCardContent>
</MudCard>
Configuração de Tema:
// Program.cs
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight;
config.SnackbarConfiguration.PreventDuplicates = false;
config.SnackbarConfiguration.ShowCloseIcon = true;
config.SnackbarConfiguration.VisibleStateDuration = 5000;
});
// App.razor - Dark Mode Binding
<MudThemeProvider @bind-IsDarkMode="@_isDarkMode" Theme="@_theme" />
@code {
private bool _isDarkMode;
private MudTheme _theme = new MudTheme();
}
Authentication - Keycloak OIDC¶
Configuração OIDC:
// Program.cs
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Keycloak", options.ProviderOptions);
options.UserOptions.RoleClaim = "roles";
});
// appsettings.json
{
"Keycloak": {
"Authority": "http://localhost:8080/realms/meajudaai",
"ClientId": "admin-portal",
"ResponseType": "code",
"Scope": "openid profile email roles"
}
}
Authentication Flow:
@* Authentication.razor *@
<RemoteAuthenticatorView Action="@Action">
<LoggingIn>
<MudProgressCircular Indeterminate="true" />
<MudText>Entrando...</MudText>
</LoggingIn>
<CompletingLoggingIn>
<MudText>Completando login...</MudText>
</CompletingLoggingIn>
<LogOut>
<MudText>Você saiu com sucesso.</MudText>
</LogOut>
<LogInFailed>
<MudAlert Severity="Severity.Error">
<MudText Typo="Typo.h6">Falha na Autenticação</MudText>
<MudText>Ocorreu um erro ao tentar fazer login.</MudText>
</MudAlert>
</LogInFailed>
</RemoteAuthenticatorView>
Protected Routes:
@* App.razor *@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
</CascadingAuthenticationState>
Component Testing - bUnit¶
Setup de Testes:
public class ProvidersPageTests : Bunit.TestContext
{
private readonly Mock<IProvidersApi> _mockProvidersApi;
private readonly Mock<IDispatcher> _mockDispatcher;
private readonly Mock<IState<ProvidersState>> _mockProvidersState;
public ProvidersPageTests()
{
_mockProvidersApi = new Mock<IProvidersApi>();
_mockDispatcher = new Mock<IDispatcher>();
_mockProvidersState = new Mock<IState<ProvidersState>>();
// Mock estado inicial
_mockProvidersState.Setup(x => x.Value).Returns(new ProvidersState());
// Registrar serviços
Services.AddSingleton(_mockProvidersApi.Object);
Services.AddSingleton(_mockDispatcher.Object);
Services.AddSingleton(_mockProvidersState.Object);
Services.AddMudServices();
// Configurar JSInterop mock (CRÍTICO para MudBlazor)
JSInterop.Mode = JSRuntimeMode.Loose;
}
[Fact]
public void Providers_Should_Dispatch_LoadAction_OnInitialized()
{
// Act
var cut = RenderComponent<Providers>();
// Assert
_mockDispatcher.Verify(
x => x.Dispatch(It.IsAny<LoadProvidersAction>()),
Times.Once);
}
[Fact]
public void Providers_Should_Display_Loading_State()
{
// Arrange
_mockProvidersState.Setup(x => x.Value)
.Returns(new ProvidersState { IsLoading = true });
// Act
var cut = RenderComponent<Providers>();
// Assert
var progressElements = cut.FindAll(".mud-progress-circular");
progressElements.Should().NotBeEmpty();
}
}
JSInterop Mock Pattern (CRÍTICO):
// SEMPRE configurar JSInterop.Mode para MudBlazor
public class MyComponentTests : Bunit.TestContext
{
public MyComponentTests()
{
Services.AddMudServices();
JSInterop.Mode = JSRuntimeMode.Loose; // <-- OBRIGATÓRIO
}
}
Padrões de Teste bUnit:
1. AAA Pattern: Arrange → Act → Assert (comentários em inglês)
2. Mock States: Sempre mockar IState
Estrutura de Arquivos¶
src/Web/MeAjudaAi.Web.Admin/
├── Pages/ # Razor pages (rotas)
│ ├── Dashboard.razor
│ ├── Providers.razor
│ └── Authentication.razor
├── Features/ # Fluxor stores por feature
│ ├── Providers/
│ │ ├── ProvidersState.cs
│ │ ├── ProvidersActions.cs
│ │ ├── ProvidersReducers.cs
│ │ └── ProvidersEffects.cs
│ ├── Dashboard/
│ │ └── ...
│ └── Theme/
│ └── ...
├── Layout/ # Layout components
│ ├── MainLayout.razor
│ └── NavMenu.razor
├── wwwroot/ # Static assets
│ ├── appsettings.json
│ └── index.html
├── Program.cs # Entry point + DI
└── App.razor # Root component
tests/MeAjudaAi.Web.Admin.Tests/
├── Pages/
│ ├── ProvidersPageTests.cs
│ └── DashboardPageTests.cs
└── Layout/
└── DarkModeToggleTests.cs
Best Practices - Frontend¶
1. State Management¶
- ✅ Use Fluxor para state compartilhado entre componentes
- ✅ Mantenha States immutable (record types)
- ✅ Reducers devem ser funções puras (sem side effects)
- ✅ Effects para chamadas assíncronas (API calls)
- ❌ Evite state local quando precisar compartilhar entre páginas
2. API Integration¶
- ✅ Use Refit para type-safe HTTP clients
- ✅ Defina interfaces em
Client.Contracts.Api - ✅ Configure authentication via
BaseAddressAuthorizationMessageHandler - ✅ Handle errors em Effects com try-catch
- ❌ Não chame API diretamente em components (use Effects)
3. Component Design¶
- ✅ Componentes pequenos e focados (Single Responsibility)
- ✅ Use MudBlazor components sempre que possível
- ✅ Bind state via
IState<T>em components - ✅ Dispatch actions via
IDispatcher - ❌ Evite lógica de negócio em components (mover para Effects)
4. Testing¶
- ✅ Sempre configure JSInterop.Mode = Loose
- ✅ Mock IState
para testar diferentes estados - ✅ Verifique Actions disparadas via Mock
- ✅ Use FluentAssertions para asserts
- ❌ Não teste MudBlazor internals (confiar na biblioteca)
5. Portuguese Localization¶
- ✅ Todas mensagens de erro em português
- ✅ Comentários inline em português
- ✅ Labels e tooltips em português
- ✅ Technical terms podem ficar em inglês (OIDC, Refit, Fluxor)
📖 Próximos Passos: Este documento serve como base para o desenvolvimento. Consulte também a documentação de infraestrutura e guia de CI/CD para informações complementares.