ADR-003 — AuditTimestamp value object: UTC instant + IANA timezone, per-timestamp
Data: 2026-05-01 Status: Aceito Contexto: DevPack 10.2.0, Bundle A
Contexto
Até a versão 2.1.1, IAuditable e ISoftDeletable usavam DateTimeOffset para
todos os campos de auditoria (Created, LastModified, DeletedOnUtc).
DateTimeOffset armazena o offset numérico no momento da gravação, não a
timezone que o produziu. Consequências práticas:
- Não é possível recalcular horário local futuro com precisão. Se um registro
foi criado em São Paulo às 12:00 BRT (
-03:00), e o Brasil reinstaurar DST no ano seguinte, é impossível saber se "12:00" daquele registro era horário padrão ou de verão sem informação adicional sobre a timezone original. - Não dá pra responder "qual foi o horário local em São Paulo?" — o offset
-03:00poderia ser de São Paulo, Buenos Aires, qualquer fuso UTC−3. - Mistura dois conceitos: o instante absoluto e a apresentação local.
Ao mesmo tempo, projetos enterprise (referência: WorkGuardian) precisam preservar
três sinais de auditoria que o DateTimeOffset original não capturava bem:
- O instante do evento (UTC inequívoco — para queries e ordenação).
- A timezone do usuário que executou o evento (para reapresentar localmente e auditar contextos como "criado às 9h da manhã do horário do criador").
- O autor do evento (
CreatedBy,ModifiedBy,DeletedBy).
Criação, atualização e deleção podem ocorrer em fusos diferentes: registro
criado em São Paulo, atualizado por agente em Lisboa, deletado por outro em Tóquio.
Um único LocalTimeZone por entidade perde essa informação.
Decisão
Introduzir um value object AuditTimestamp que encapsula o par (instante UTC,
timezone IANA), e usar uma instância por evento de auditoria:
[Owned]
public sealed record AuditTimestamp(DateTime UtcDateTime, string? LocalTimeZone)
{
public static AuditTimestamp Now(string? localTimeZone) => new(DateTime.UtcNow, localTimeZone);
public DateTimeOffset ToLocal() { /* IANA conversion, fallback UTC */ }
}
Contratos resultantes:
public interface IAuditable : IInactivable
{
AuditTimestamp CreatedAt { get; }
string? CreatedBy { get; }
AuditTimestamp? UpdatedAt { get; }
string? ModifiedBy { get; } // renomeado de LastModifiedBy
void SetCreatedAudit(string? createdBy, AuditTimestamp createdAt);
void SetUpdatedAudit(string? modifiedBy, AuditTimestamp updatedAt);
}
public interface ISoftDeletable
{
bool IsDeleted { get; }
AuditTimestamp? DeletedAt { get; }
string? DeletedBy { get; }
void SoftDelete(string? deletedBy, AuditTimestamp deletedAt);
}
null em LocalTimeZone significa UTC — interpretação para seeds, migrations,
jobs sem usuário.
Mapeamento EF Core
AuditTimestamp é anotado com [Owned]. EF auto-mapeia como propriedade owned
e persiste cada AuditTimestamp em duas colunas:
CreatedAt_UtcDateTime datetime2 NOT NULL
CreatedAt_LocalTimeZone nvarchar(64) NULL
UpdatedAt_UtcDateTime datetime2 NULL
UpdatedAt_LocalTimeZone nvarchar(64) NULL
DeletedAt_UtcDateTime datetime2 NULL
DeletedAt_LocalTimeZone nvarchar(64) NULL
Sem configuração manual no OnModelCreating para o caso comum.
Reconstrução do horário local
DateTimeOffset local = entity.CreatedAt.ToLocal(); // IANA aplicado, fallback UTC
DateTime utc = entity.CreatedAt.UtcDateTime; // queryable em LINQ-to-EF
AuditTimestamp.ToLocal() usa TimeZoneInfo.TryFindSystemTimeZoneById (.NET 8+)
e degrada silenciosamente para UTC quando o IANA id é desconhecido — nunca lança.
Alternativas consideradas
Um único LocalTimeZone por entidade (proposta inicial do prompt)
public AuditTimestamp CreatedAt { get; }
public AuditTimestamp UpdatedAt { get; }
public string? LocalTimeZone { get; } // único, capturado na criação
Descartada. Cenário canônico: registro criado em São Paulo, atualizado por
agente em Lisboa. O update aconteceu às 15h local de Lisboa, não às 11h local
de São Paulo. Um único LocalTimeZone força reescrever a timezone (perdendo a
informação da criação) ou manter a antiga (mentindo sobre o contexto do update).
Per-timestamp resolve sem ambiguidade.
Remover atribuição de usuário (CreatedBy/ModifiedBy/DeletedBy)
Aplicada inicialmente, depois revertida via feedback do tech lead. A motivação para removê-las (audit log dedicado seria responsável) foi descartada porque:
- Audit log dedicado é decisão por projeto, não convenção do DevPack.
IAuditablehistoricamente carregava atribuição — remover quebra muito código.- A pergunta "quem criou esse registro?" via leitura direta da linha tem valor ergonômico significativo. Cruzar com tabela de audit log a cada query é fricção desnecessária.
A atribuição volta com LastModifiedBy renomeado para ModifiedBy (consistência
com UpdatedAt).
HasConversion<string> em coluna única
builder.Property(e => e.CreatedAt)
.HasConversion(v => $"{v.UtcDateTime:O}|{v.LocalTimeZone}", s => Parse(s));
Descartada como default. Compacta (1 coluna), mas perde queryability dos campos
individuais (WHERE r.CreatedAt.UtcDateTime > X deixa de funcionar). Para audit,
queries por timestamp são frequentes — [Owned] (2 colunas) é melhor trade-off.
HasConversion permanece disponível como override opcional para projetos que
prefiram coluna única, documentado como alternativa no Domain/README.md.
ComplexProperty (EF Core 8+) em vez de [Owned]
ComplexProperty é a forma moderna de mapear value objects sem identidade EF.
Funcionalmente equivalente ao [Owned] para o caso aqui — duas colunas, sem
tracking. A diferença é semântica: [Owned] cria FK oculto; ComplexProperty não.
Escolhemos [Owned] porque é maduro desde EF 2.x, suportado por todos os providers
(InMemory inclusive — usado nos testes), e a diferença não vaza para a API pública.
Migrar para ComplexProperty em uma release futura é mecânico (remover atributo,
configurar via fluent API).
Manter DateTimeOffset + adicionar coluna de timezone
Descartada. Duplica informação (offset já está no DateTimeOffset) e não
elimina a ambiguidade da timezone original.
Consequências
- Breaking change em
IAuditable/ISoftDeletable/AuditedEntity/AuditedSoftDeletableEntity/BaseIdentityUser/BaseIdentityRole. Renames:Created→CreatedAt,LastModified→UpdatedAt,DeletedOnUtc→DeletedAt,LastModifiedBy→ModifiedBy. Tipos:DateTimeOffset→AuditTimestamp. - Migration SQL necessária nos consumidores: cada coluna
datetimeoffsetvira duas colunas ({Property}_UtcDateTimedatetime2+{Property}_LocalTimeZonenvarchar(64)). Schema migration é mecânica mas não automática. AuditableEntityInterceptoragora recebeIServiceProvidere resolveICurrentUserpor escopo viaCreateScope()— evita captura scoped-em-singleton.SoftDeleteInterceptorrecebe(IServiceProvider, TimeProvider)pelo mesmo motivo.- Cobertura de testes: novo teste-chave valida que update por usuário em
Lisboa não muda o
CreatedAt.LocalTimeZoneoriginal (São Paulo) — o cenário-mãe da redesign. - Apresentação local barata:
entity.CreatedAt.ToLocal()reconstrói oDateTimeOffsetna timezone original, ou degrada para UTC se desconhecida.
Referências
- ADR-001 — No framework wrappers (princípio que orientou manter
AuditTimestampcomo value object puro, sem dependência EF além do attribute[Owned]). - ADR-002 — Rename Mediator → Cqrs (precedente de breaking change com namespace rename + guia de migração detalhado no CHANGELOG).
docs/devpack-10.2.0-bundle-a-relatorio.md— relatório operacional do Bundle A.Domain/AuditTimestamp.csDomain/IAuditable.csDomain/ISoftDeletable.cs