Codebelt

Savvyio.App

One package reference for the full Savvy I/O DDD, CQRS, event sourcing, and integration stack.

.NET 10.0 / .NET 9.0 MIT v5.0.8 19,633 downloads

Overview

Savvyio.App is the aggregate package for installing the Savvy I/O application stack with one NuGet reference. The package is metadata-only: src/Savvyio.App/Savvyio.App.csproj sets IncludeBuildOutput to false and contributes project references instead of shipping its own public API surface.

Those references span commands, queries, domain and traced aggregates, integration-event messaging, dependency injection registration, dispatching, Dapper and Entity Framework Core persistence, JSON marshalling, and transport adapters for NATS, Azure Queue Storage and Event Grid, RabbitMQ, and Amazon SQS and SNS. When you install Savvyio.App, you use APIs owned by those referenced packages, not APIs declared by Savvyio.App itself.

Key APIs

Savvyio.App exposes no additional public APIs. It is a metadata-only aggregate package, so the consumer-facing types come from the referenced packages declared in src/Savvyio.App/Savvyio.App.csproj, including Savvyio.Commands, Savvyio.Domain, Savvyio.EventDriven, Savvyio.Messaging, Savvyio.Queries, and the referenced Savvyio.Extensions.* persistence, DI, serialization, dispatching, and transport packages.

Basic usage

Savvyio.App contributes the single package reference. Each example below targets an API owned by one referenced package that becomes available through that aggregate dependency.

Savvyio.Commands

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Savvyio.Commands;
using Savvyio.Dispatchers;
using Savvyio.Handlers;
using Xunit;

namespace MyProject.Tests;

public class SavvyioCommandsTests : Test
{
    public SavvyioCommandsTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public async Task Commit_MissingHandler_ThrowsOrphanedHandlerException()
    {
        var dispatcher = new CommandDispatcher(new EmptyServiceLocator());
        var command = new ArchiveOrderCommand();

        var sync = Assert.Throws<OrphanedHandlerException>(() => dispatcher.Commit(command));
        var async = await Assert.ThrowsAsync<OrphanedHandlerException>(() => dispatcher.CommitAsync(command));

        TestOutput.WriteLine(sync.Message);
        TestOutput.WriteLine(async.Message);
        Assert.Contains(nameof(ArchiveOrderCommand), sync.Message, StringComparison.Ordinal);
    }
}

public sealed record ArchiveOrderCommand : Command;

public sealed class EmptyServiceLocator : IServiceLocator
{
    public IEnumerable<object> GetServices(Type serviceType) => Array.Empty<object>();
}

Savvyio.Commands.Messaging

using System;
using Codebelt.Extensions.Xunit;
using Savvyio.Commands;
using Savvyio.Commands.Messaging;
using Xunit;

namespace MyProject.Tests;

public class CommandsMessagingExample : Test
{
    public CommandsMessagingExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ToMessage_CommandPayload_WrapsCommandInEnvelope()
    {
        var message = new ApproveInvoice("INV-42")
            .ToMessage(new Uri("urn:billing:approve"), nameof(ApproveInvoice), o => o.MessageId = "msg-inv-42");

        TestOutput.WriteLine($"Envelope: {message.Id} -> {message.Source}");
        Assert.Equal("msg-inv-42", message.Id);
        Assert.Equal("INV-42", message.Data.InvoiceNumber);
        Assert.Equal(nameof(ApproveInvoice), message.Type);
    }

    private sealed record ApproveInvoice(string InvoiceNumber) : Command;
}

Savvyio.Core

using Codebelt.Extensions.Xunit;
using Savvyio;
using Xunit;

namespace MyProject.Tests;

public class CoreExample : Test
{
    public CoreExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void SaveMetadata_CustomMetadata_StoresReservedAndCustomValues()
    {
        var metadata = new TestMetadata()
            .SetCorrelationId("corr-42")
            .SetRequestId("req-42")
            .SaveMetadata("tenantId", 7);

        TestOutput.WriteLine($"{metadata.GetRequestId()} / {metadata.GetCorrelationId()}");
        Assert.Equal("corr-42", metadata.GetCorrelationId());
        Assert.Equal("req-42", metadata.GetRequestId());
        Assert.Equal(7, metadata.Metadata["tenantId"]);
    }

    private sealed class TestMetadata : IMetadata
    {
        public IMetadataDictionary Metadata { get; } = new MetadataDictionary();
    }
}

Savvyio.Domain

using System;
using Codebelt.Extensions.Xunit;
using Savvyio.Domain;
using Xunit;

namespace MyProject.Tests;

public class DomainExample : Test
{
    public DomainExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void Archive_AddedDomainEvent_TracksAndClearsPendingEvents()
    {
        var aggregate = new InvoiceAggregate(Guid.NewGuid());

        aggregate.Archive();

        var @event = Assert.IsType<InvoiceArchived>(Assert.Single(aggregate.Events));
        TestOutput.WriteLine($"Pending events: {aggregate.Events.Count}");
        Assert.Equal(aggregate.Id, @event.InvoiceId);

        aggregate.RemoveAllEvents();
        Assert.Empty(aggregate.Events);
    }

    private sealed class InvoiceAggregate : AggregateRoot<Guid>
    {
        public InvoiceAggregate(Guid id) : base(id) { }

        public void Archive() => AddEvent(new InvoiceArchived(Id));
    }

    private sealed record InvoiceArchived(Guid InvoiceId) : DomainEvent;
}

Savvyio.Domain.EventSourcing

using Codebelt.Extensions.Xunit;
using Savvyio.Domain.EventSourcing;
using Xunit;

namespace MyProject.Tests;

public class TracedDomainEventMetadataExample : Test
{
    public TracedDomainEventMetadataExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void StampMetadata_TracedDomainEvent_PreservesAggregateVersion()
    {
        var domainEvent = new AccountEmailAddressChanged("root@gimlichael.dev")
            .SetAggregateVersion(42);

        TestOutput.WriteLine($"AggregateVersion: {domainEvent.GetAggregateVersion()}");
        TestOutput.WriteLine($"MemberType: {domainEvent.GetMemberType()}");

        Assert.Equal(42, domainEvent.GetAggregateVersion());
        Assert.Contains(nameof(AccountEmailAddressChanged), domainEvent.GetMemberType());
    }
}

public sealed record AccountEmailAddressChanged(string EmailAddress) : TracedDomainEvent;

Savvyio.EventDriven

using System;
using Codebelt.Extensions.Xunit;
using Savvyio.EventDriven;
using Xunit;

namespace MyProject.Tests;

public class IntegrationEventUsage : Test
{
    public IntegrationEventUsage(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void IntegrationEvent_NewInstance_PopulatesMetadata()
    {
        var before = DateTime.UtcNow;
        var @event = new AccountExported("crm", 12);
        var after = DateTime.UtcNow;

        TestOutput.WriteLine($"EventId: {@event.GetEventId()}");
        TestOutput.WriteLine($"Timestamp: {@event.GetTimestamp():O}");
        TestOutput.WriteLine($"MemberType: {@event.GetMemberType()}");

        Assert.Equal("crm", @event.Destination);
        Assert.Equal(12, @event.Count);
        Assert.False(string.IsNullOrWhiteSpace(@event.GetEventId()));
        Assert.InRange(@event.GetTimestamp(), before, after.AddSeconds(1));
        Assert.Contains(nameof(AccountExported), @event.GetMemberType());
    }

    private sealed record AccountExported(string Destination, int Count) : IntegrationEvent();
}

Savvyio.EventDriven.Messaging

using System;
using System.Collections.Generic;
using Codebelt.Extensions.Xunit;
using Savvyio.EventDriven;
using Savvyio.EventDriven.Messaging;
using Savvyio.EventDriven.Messaging.CloudEvents;
using Xunit;

namespace MyProject.Tests;

public class EventDrivenMessagingExample : Test
{
    public EventDrivenMessagingExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ToCloudEvent_IntegrationEventMessage_PreservesEnvelopeAndExtensions()
    {
        var message = new OrderExported("SO-42")
            .ToMessage(new Uri("urn:sales:orders"), nameof(OrderExported));
        var cloudEvent = (CloudEvent<OrderExported>)message.ToCloudEvent();
        IDictionary<string, object> attributes = cloudEvent;

        attributes.Add("tenantId", 7);

        TestOutput.WriteLine($"specversion: {cloudEvent.Specversion}");
        Assert.Equal("1.0", cloudEvent.Specversion);
        Assert.Equal("SO-42", cloudEvent.Data.OrderNumber);
        Assert.Equal(7, cloudEvent["tenantid"]);
    }

    private sealed record OrderExported(string OrderNumber) : IntegrationEvent;
}

Savvyio.Extensions.DependencyInjection

using System;
using System.Linq;
using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Dispatchers;
using Savvyio.Extensions.DependencyInjection;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionExample : Test
{
    public DependencyInjectionExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddServiceLocator_CustomFactory_ResolvesConfiguredServices()
    {
        var services = new ServiceCollection();
        services.AddServiceLocator(o =>
        {
            o.Lifetime = ServiceLifetime.Singleton;
            o.ImplementationFactory = _ => new ServiceLocator(type => type == typeof(string) ? new object[] { "configured" } : Array.Empty<object>());
        });
        var provider = services.BuildServiceProvider();

        var locator1 = provider.GetRequiredService<IServiceLocator>();
        var locator2 = provider.GetRequiredService<IServiceLocator>();
        var configured = locator1.GetServices(typeof(string)).Cast<string>().Single();

        TestOutput.WriteLine(configured);
        Assert.Same(locator1, locator2);
        Assert.Equal("configured", configured);
    }
}

Savvyio.Extensions.Dispatchers

using System;
using System.Collections.Generic;
using Codebelt.Extensions.Xunit;
using Savvyio.Commands;
using Savvyio.Dispatchers;
using Savvyio.Extensions;
using Savvyio.Handlers;
using Savvyio.Queries;
using Xunit;

namespace MyProject.Tests;

public class MediatorWorkflowTest : Test
{
    public MediatorWorkflowTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void Mediator_MissingHandlers_ThrowsOrphanedHandlerException()
    {
        var sut = new Mediator(new EmptyServiceLocator());

        var commandError = Assert.Throws<OrphanedHandlerException>(() => sut.Commit(new ArchiveOrder("A-42")));
        var queryError = Assert.Throws<OrphanedHandlerException>(() => sut.Query(new ReadOrderStatus()));

        TestOutput.WriteLines(commandError.Message, queryError.Message);
        Assert.Contains(nameof(ArchiveOrder), commandError.Message, StringComparison.Ordinal);
        Assert.Contains(nameof(ReadOrderStatus), queryError.Message, StringComparison.Ordinal);
    }
}

public sealed record ArchiveOrder(string OrderId) : Command;
public sealed record ReadOrderStatus : Query<string>;

public sealed class EmptyServiceLocator : IServiceLocator
{
    public IEnumerable<object> GetServices(Type serviceType) => Array.Empty<object>();
}

Savvyio.Extensions.Dapper

using System;
using System.Data;
using Codebelt.Extensions.Xunit;
using Dapper;
using Savvyio.Extensions.Dapper;
using Xunit;

namespace MyProject.Tests;

public sealed class DapperQueryOptionsDocumentationTests : Test
{
    public DapperQueryOptionsDocumentationTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ToCommandDefinition_ConfiguredQuery_BuildsCommandDefinition()
    {
        var options = new DapperQueryOptions
        {
            CommandText = "SELECT * FROM Invoices WHERE CustomerId = @CustomerId",
            Parameters = new { CustomerId = 42 },
            CommandTimeout = TimeSpan.FromSeconds(15),
            CommandType = CommandType.Text,
            CommandFlags = CommandFlags.Buffered
        };

        CommandDefinition command = options;

        TestOutput.WriteLine($"{command.CommandText} [{command.CommandTimeout}s]");
        Assert.Equal("SELECT * FROM Invoices WHERE CustomerId = @CustomerId", command.CommandText);
        Assert.Equal(15, command.CommandTimeout);
        Assert.Equal(CommandType.Text, command.CommandType);
        Assert.Equal(CommandFlags.Buffered, command.Flags);
        Assert.Same(options.Parameters, command.Parameters);
    }
}

Savvyio.Extensions.DependencyInjection.Dapper

using System;
using System.Data;
using System.Data.Common;
using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.Dapper;
using Xunit;

namespace MyProject.Tests;

public class DapperDataSourceRegistrationTest : Test
{
    public DapperDataSourceRegistrationTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddDapperDataSource_WithMarker_RegistersATypeForwardedSource()
    {
        var services = new ServiceCollection();
        services.AddDapperDataSource<ReadModelStore>(o => o.ConnectionFactory = () => new InMemoryConnection());

        using var provider = services.BuildServiceProvider();
        var dapper = provider.GetRequiredService<IDapperDataSource<ReadModelStore>>();
        var forwarded = provider.GetRequiredService<IDataSource<ReadModelStore>>();

        TestOutput.WriteLine($"Resolved {dapper.GetType().Name} with state {dapper.State}.");
        Assert.IsType<DapperDataSource<ReadModelStore>>(dapper);
        Assert.Equal(ConnectionState.Open, dapper.State);
        Assert.Same(dapper, forwarded);
    }

    private sealed class ReadModelStore { }

    private sealed class InMemoryConnection : DbConnection
    {
        private ConnectionState _state = ConnectionState.Closed;
        public override string ConnectionString { get; set; } = "memory";
        public override string Database => "memory";
        public override string DataSource => "memory";
        public override string ServerVersion => "1.0";
        public override ConnectionState State => _state;
        public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
        public override void Close() => _state = ConnectionState.Closed;
        public override void Open() => _state = ConnectionState.Open;
        protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
        protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
    }
}

Savvyio.Extensions.DependencyInjection.Domain

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon.Threading;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Domain;
using Savvyio.Extensions.DependencyInjection.Domain;
using Xunit;

namespace MyProject.Tests;

public class AggregateRepositoryRegistrationTest : Test
{
    public AggregateRepositoryRegistrationTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddAggregateRepository_CustomType_RegistersContracts()
    {
        var services = new ServiceCollection();
        services.AddAggregateRepository<LedgerAccountRepository, LedgerAccountAggregate, Guid>(o => o.Lifetime = ServiceLifetime.Singleton);
        using var provider = services.BuildServiceProvider();

        var aggregateRepository = provider.GetRequiredService<IAggregateRepository<LedgerAccountAggregate, Guid, PrimaryStoreMarker>>();
        var persistentRepository = provider.GetRequiredService<IPersistentRepository<LedgerAccountAggregate, Guid, PrimaryStoreMarker>>();

        TestOutput.WriteLine($"Resolved {aggregateRepository.GetType().Name} for aggregate and persistent repository contracts.");
        Assert.IsType<LedgerAccountRepository>(aggregateRepository);
        Assert.IsType<LedgerAccountRepository>(persistentRepository);
    }
}

public sealed class LedgerAccountAggregate : AggregateRoot<Guid>
{
    public LedgerAccountAggregate(Guid id) : base(id) { }
}

public sealed class PrimaryStoreMarker;

public sealed class LedgerAccountRepository : IAggregateRepository<LedgerAccountAggregate, Guid, PrimaryStoreMarker>
{
    public void Add(LedgerAccountAggregate entity) { }
    public void AddRange(IEnumerable<LedgerAccountAggregate> entities) { }
    public Task<IEnumerable<LedgerAccountAggregate>> FindAllAsync(Expression<Func<LedgerAccountAggregate, bool>>? predicate = null, Action<AsyncOptions>? setup = null) =>
        Task.FromResult<IEnumerable<LedgerAccountAggregate>>(Array.Empty<LedgerAccountAggregate>());
    public Task<LedgerAccountAggregate> GetByIdAsync(Guid id, Action<AsyncOptions>? setup = null) =>
        Task.FromResult(new LedgerAccountAggregate(id));
    public void Remove(LedgerAccountAggregate entity) { }
    public void RemoveRange(IEnumerable<LedgerAccountAggregate> entities) { }
}

Savvyio.Extensions.EFCore

using System;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Microsoft.EntityFrameworkCore;
using Savvyio.Extensions.EFCore;
using Xunit;

namespace MyProject.Tests;

public class EfCoreDataSourceTests : Test
{
    public EfCoreDataSourceTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public async Task SaveChanges_MissingProvider_ThrowsInvalidOperationException()
    {
        var options = new EfCoreDataSourceOptions
        {
            ContextConfigurator = builder => builder.EnableSensitiveDataLogging(),
            ModelConstructor = _ => { },
            ConventionsConfigurator = _ => { }
        };

        using var source = new EfCoreDataSource(options);
        var context = Assert.IsType<EfCoreDbContext>(source.DbContext);

        TestOutput.WriteLine($"Context type: {context.GetType().Name}");
        Assert.Same(options, context.Options);

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => source.SaveChangesAsync());

        TestOutput.WriteLine(ex.Message);
        Assert.StartsWith("No database provider has been configured for this DbContext.", ex.Message);
    }
}

Savvyio.Extensions.DependencyInjection.EFCore

using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.EFCore;
using Xunit;

namespace MyProject.Tests;

public class EfCoreRegistrationTests : Test
{
    public EfCoreRegistrationTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddEfCoreDataStore_ConfiguredMarker_RegistersServices()
    {
        var services = new ServiceCollection();
        services.AddEfCoreDataSource<ReadModelMarker>(o => o.ContextConfigurator = _ => { });
        services.AddEfCoreDataStore<LedgerRow, ReadModelMarker>();

        TestOutput.WriteLine($"Registered services: {services.Count}");
        Assert.Contains(services, d => d.ServiceType == typeof(IEfCoreDataSource<ReadModelMarker>));
        Assert.Contains(services, d => d.ServiceType.Name.Contains("IPersistentDataStore"));
    }
}

public sealed class ReadModelMarker { }
public sealed class LedgerRow { }

Savvyio.Extensions.EFCore.Domain

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Savvyio.Domain;
using Savvyio.Extensions.EFCore;
using Savvyio.Extensions.EFCore.Domain;
using Xunit;

namespace MyProject.Tests;

public class AggregateRepositoryTests : Test
{
    public AggregateRepositoryTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public async Task Add_AggregateRoot_TracksUnitOfWork()
    {
        var source = new RecordingEfCoreDataSource();
        var repository = new EfCoreAggregateRepository<InventoryItem, int>(source);
        var item = new InventoryItem(42, "SKU-42");

        repository.Add(item);
        await source.SaveChangesAsync();

        TestOutput.WriteLine($"Tracked {source.Items.Single().Sku}; pending events: {item.Events.Count}.");
        Assert.Single(source.Items);
        Assert.Same(item, source.Items.Single());
        Assert.Equal(1, source.SaveChangesCalls);
        Assert.IsType<InventoryItemRegistered>(item.Events.Single());
    }
}

sealed class RecordingEfCoreDataSource : IEfCoreDataSource
{
    public int SaveChangesCalls { get; private set; }
    public List<InventoryItem> Items { get; } = new();

    public DbSet<TEntity> Set<TEntity>() where TEntity : class
    {
        if (typeof(TEntity) != typeof(InventoryItem)) { throw new InvalidOperationException($"Unsupported entity type: {typeof(TEntity)}."); }
        return (DbSet<TEntity>)(object)new FakeDbSet<InventoryItem>(Items);
    }

    public Task SaveChangesAsync(Action<AsyncOptions> setup = null!)
    {
        SaveChangesCalls++;
        return Task.CompletedTask;
    }
}

sealed class FakeDbSet<TEntity>(List<TEntity> items) : DbSet<TEntity>, IQueryable<TEntity>, IEnumerable<TEntity>, IEnumerable where TEntity : class
{
    private readonly IQueryable<TEntity> _query = items.AsQueryable();

    public override EntityEntry<TEntity> Add(TEntity entity)
    {
        items.Add(entity);
        return null!;
    }

    public override IEntityType EntityType => null!;
    public Type ElementType => _query.ElementType;
    public Expression Expression => _query.Expression;
    public IQueryProvider Provider => _query.Provider;
    public IEnumerator<TEntity> GetEnumerator() => items.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

sealed class InventoryItem : AggregateRoot<int>
{
    public InventoryItem(int id, string sku) : base(id)
    {
        Sku = sku;
        AddEvent(new InventoryItemRegistered(sku));
    }

    public string Sku { get; }
}

sealed record InventoryItemRegistered(string Sku) : DomainEvent;

Savvyio.Extensions.DependencyInjection.EFCore.Domain

using System;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon.Threading;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Domain;
using Savvyio.Extensions.DependencyInjection.Domain;
using Savvyio.Extensions.DependencyInjection.EFCore;
using Savvyio.Extensions.DependencyInjection.EFCore.Domain;
using Xunit;

namespace MyProject.Tests;

public class AggregateDataSourceRegistrationTest : Test
{
    public AggregateDataSourceRegistrationTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddEfCoreAggregateDataSource_WithMarker_RegistersAggregateAwareContracts()
    {
        var services = new ServiceCollection();
        services.AddSingleton<IDomainEventDispatcher, SilentDomainEventDispatcher>();
        services.AddEfCoreAggregateDataSource<OrdersStore>(o => o.ContextConfigurator = _ => { });

        using var provider = services.BuildServiceProvider();
        var source = provider.GetRequiredService<IEfCoreDataSource<OrdersStore>>();
        var unitOfWork = provider.GetRequiredService<IUnitOfWork<OrdersStore>>();

        TestOutput.WriteLine($"Resolved {source.GetType().Name} for aggregate-aware EF Core access.");
        Assert.IsType<EfCoreAggregateDataSource<OrdersStore>>(source);
        Assert.IsType<EfCoreAggregateDataSource<OrdersStore>>(unitOfWork);
    }

    private sealed class OrdersStore;

    private sealed class SilentDomainEventDispatcher : IDomainEventDispatcher
    {
        public void Raise(IDomainEvent request) { }

        public Task RaiseAsync(IDomainEvent request, Action<AsyncOptions>? setup = null)
        {
            return Task.CompletedTask;
        }
    }
}

Savvyio.Extensions.EFCore.Domain.EventSourcing

using System;
using Codebelt.Extensions.Xunit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Savvyio.Domain.EventSourcing;
using Savvyio.Extensions.EFCore.Domain.EventSourcing;
using Savvyio.Handlers;
using Xunit;

namespace MyProject.Tests;

public class EventSourcingModelBuilderTest : Test
{
    public EventSourcingModelBuilderTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddEventSourcing_CustomSchema_UpdatesEventStoreMap()
    {
        var modelBuilder = new ModelBuilder(new ConventionSet());
        modelBuilder.AddEventSourcing<BankAccount, Guid>(o =>
        {
            o.TableName = "TraceEvents";
            o.CompositePrimaryKeyIdColumnName = "aggregate_id";
            o.CompositePrimaryKeyVersionColumnName = "aggregate_version";
            o.TimestampColumnName = "recorded_at";
            o.TypeColumnName = "aggregate_type";
            o.PayloadColumnName = "body";
        });

        var schema = modelBuilder.Model.ToDebugString(MetadataDebugStringOptions.LongDefault);
        TestOutput.WriteLine(schema);

        Assert.Contains("Relational:TableName: TraceEvents", schema);
        Assert.Contains("Relational:ColumnName: aggregate_id", schema);
        Assert.Contains("Relational:ColumnName: aggregate_version", schema);
        Assert.Contains("Relational:ColumnName: recorded_at", schema);
        Assert.Contains("Relational:ColumnName: aggregate_type", schema);
        Assert.Contains("Relational:ColumnName: body", schema);
    }
}

file sealed class BankAccount : TracedAggregateRoot<Guid>
{
    protected override void RegisterDelegates(IFireForgetRegistry<ITracedDomainEvent> handler) { }
}

Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing

using System;
using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio;
using Savvyio.Domain.EventSourcing;
using Savvyio.Extensions.DependencyInjection.EFCore;
using Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing;
using Xunit;

namespace MyProject.Tests;

public class EfCoreTracedAggregateRepositoryRegistrationTest : Test
{
    public EfCoreTracedAggregateRepositoryRegistrationTest(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddEfCoreTracedAggregateRepository_ConfiguredMarker_RegistersServices()
    {
        var services = new ServiceCollection();
        services.AddSingleton<IMarshaller, FakeMarshaller>();
        services.AddSingleton<IEfCoreDataSource<RepositoryMarker>, FakeEfCoreDataSource>();
        services.AddEfCoreTracedAggregateRepository<BankAccount, Guid, RepositoryMarker>();

        TestOutput.WriteLine($"Registered services: {services.Count}");
        Assert.Contains(services, d => d.ServiceType.Name.Contains("ITracedAggregateRepository"));
        Assert.Contains(services, d => d.ServiceType.Name.Contains("IReadableRepository"));
        Assert.Contains(services, d => d.ServiceType.Name.Contains("IWritableRepository"));
    }
}

public sealed class BankAccount : TracedAggregateRoot<Guid>
{
    protected override void RegisterDelegates(Savvyio.Handlers.IFireForgetRegistry<ITracedDomainEvent> handler) { }
}

public sealed class RepositoryMarker { }

public sealed class FakeEfCoreDataSource : IEfCoreDataSource<RepositoryMarker>
{
    public Microsoft.EntityFrameworkCore.DbSet<TEntity> Set<TEntity>() where TEntity : class => throw new NotSupportedException();
    public System.Threading.Tasks.Task SaveChangesAsync(Action<Cuemon.Threading.AsyncOptions>? setup = null) => System.Threading.Tasks.Task.CompletedTask;
}

public sealed class FakeMarshaller : IMarshaller
{
    public System.IO.Stream Serialize<TValue>(TValue value) => System.IO.Stream.Null;
    public System.IO.Stream Serialize(object value, Type inputType) => System.IO.Stream.Null;
    public TValue Deserialize<TValue>(System.IO.Stream data) => throw new NotSupportedException();
    public object Deserialize(System.IO.Stream data, Type returnType) => throw new NotSupportedException();
}

Savvyio.Extensions.NATS

using System;
using Codebelt.Extensions.Xunit;
using Savvyio.Extensions.NATS.Commands;
using Xunit;

namespace MyProject.Tests;

public class NatsExample : Test
{
    public NatsExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ValidateOptions_ExpiresThreshold_AdjustsHeartbeatAndMessageLimit()
    {
        var options = new NatsCommandQueueOptions
        {
            Subject = "orders.approve",
            StreamName = "orders",
            ConsumerName = "approvals",
            MaxMessages = 0,
            Expires = TimeSpan.FromSeconds(30)
        };

        options.ValidateOptions();

        TestOutput.WriteLine($"MaxMessages={options.MaxMessages}, Heartbeat={options.Heartbeat}");
        Assert.Equal(1, options.MaxMessages);
        Assert.Equal(TimeSpan.FromSeconds(5), options.Heartbeat);
    }
}

Savvyio.Extensions.DependencyInjection.NATS

using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Commands;
using Savvyio.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.NATS;
using Savvyio.Extensions.NATS.Commands;
using Savvyio.Extensions.Text.Json;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionNatsExample : Test
{
    public DependencyInjectionNatsExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddNatsCommandQueue_ConfiguredServices_ResolvesCommandQueue()
    {
        var services = new ServiceCollection();
        services.AddMarshaller<JsonMarshaller>();
        services.AddNatsCommandQueue(o =>
        {
            o.Subject = "orders.approve";
            o.StreamName = "orders";
            o.ConsumerName = "orders-worker";
        });
        var provider = services.BuildServiceProvider();

        var queue = provider.GetRequiredService<IPointToPointChannel<ICommand>>();

        TestOutput.WriteLine(queue.GetType().FullName);
        Assert.IsType<NatsCommandQueue>(queue);
    }
}

Savvyio.Extensions.QueueStorage

using System;
using Azure;
using Codebelt.Extensions.Xunit;
using Savvyio.Extensions.QueueStorage.EventDriven;
using Xunit;

namespace MyProject.Tests;

public class QueueStorageExample : Test
{
    public QueueStorageExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ValidateOptions_SasCredentialConfigured_ClearsOtherCredentialTypes()
    {
        var options = new AzureEventBusOptions
        {
            TopicEndpoint = new Uri("https://orders.example.com/api/events"),
            SasCredential = new AzureSasCredential("sig")
        };

        options.ValidateOptions();

        TestOutput.WriteLine(options.TopicEndpoint.OriginalString);
        Assert.Null(options.Credential);
        Assert.Null(options.KeyCredential);
        Assert.NotNull(options.SasCredential);
    }
}

Savvyio.Extensions.DependencyInjection.QueueStorage

using System;
using Azure;
using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.EventDriven;
using Savvyio.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.QueueStorage;
using Savvyio.Extensions.QueueStorage.EventDriven;
using Savvyio.Extensions.Text.Json;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionQueueStorageExample : Test
{
    public DependencyInjectionQueueStorageExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddAzureEventBus_ConfiguredQueueAndTopic_ResolvesPublishSubscribeChannel()
    {
        var services = new ServiceCollection();
        services.AddMarshaller<JsonMarshaller>();
        services.AddAzureEventBus(
            queue =>
            {
                queue.StorageAccountName = "account";
                queue.QueueName = "orders";
                queue.SasCredential = new AzureSasCredential("queue-sas");
            },
            bus =>
            {
                bus.TopicEndpoint = new Uri("https://example.com");
                bus.SasCredential = new AzureSasCredential("topic-sas");
            });
        var provider = services.BuildServiceProvider();

        var eventBus = provider.GetRequiredService<IPublishSubscribeChannel<IIntegrationEvent>>();

        TestOutput.WriteLine(eventBus.GetType().FullName);
        Assert.IsType<AzureEventBus>(eventBus);
    }
}

Savvyio.Extensions.RabbitMQ

using Codebelt.Extensions.Xunit;
using Savvyio.Extensions.RabbitMQ.Commands;
using Xunit;

namespace MyProject.Tests;

public class RabbitMqExample : Test
{
    public RabbitMqExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ValidateOptions_QueueNameConfigured_KeepsDurableDefault()
    {
        var options = new RabbitMqCommandQueueOptions
        {
            QueueName = "orders.approve"
        };

        options.ValidateOptions();

        TestOutput.WriteLine(options.AmqpUrl.OriginalString);
        Assert.True(options.Durable);
        Assert.False(options.AutoDelete);
        Assert.Equal("amqp://localhost:5672", options.AmqpUrl.OriginalString);
    }
}

Savvyio.Extensions.DependencyInjection.RabbitMQ

using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.EventDriven;
using Savvyio.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.RabbitMQ;
using Savvyio.Extensions.RabbitMQ.EventDriven;
using Savvyio.Extensions.Text.Json;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionRabbitMqExample : Test
{
    public DependencyInjectionRabbitMqExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddRabbitMqEventBus_ConfiguredExchange_ResolvesPublishSubscribeChannel()
    {
        var services = new ServiceCollection();
        services.AddMarshaller<JsonMarshaller>();
        services.AddRabbitMqEventBus(o => o.ExchangeName = "orders.events");
        var provider = services.BuildServiceProvider();

        var eventBus = provider.GetRequiredService<IPublishSubscribeChannel<IIntegrationEvent>>();

        TestOutput.WriteLine(eventBus.GetType().FullName);
        Assert.IsType<RabbitMqEventBus>(eventBus);
    }
}

Savvyio.Extensions.SimpleQueueService

using Codebelt.Extensions.Xunit;
using Savvyio.Extensions.SimpleQueueService.EventDriven;
using Xunit;

namespace MyProject.Tests;

public class SimpleQueueServiceExample : Test
{
    public SimpleQueueServiceExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void ToSnsUri_CustomAccountAndRegion_BuildsExpectedArn()
    {
        var topic = "orders-created".ToSnsUri(o =>
        {
            o.Region = "eu-west-1";
            o.AccountId = "123456789012";
        });

        TestOutput.WriteLine(topic.OriginalString);
        Assert.Equal("arn:aws:sns:eu-west-1:123456789012:orders-created", topic.OriginalString);
    }
}

Savvyio.Extensions.DependencyInjection.SimpleQueueService

using System;
using Amazon;
using Amazon.Runtime;
using Codebelt.Extensions.Xunit;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Commands;
using Savvyio.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.SimpleQueueService;
using Savvyio.Extensions.SimpleQueueService.Commands;
using Savvyio.Extensions.Text.Json;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionSimpleQueueServiceExample : Test
{
    public DependencyInjectionSimpleQueueServiceExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddAmazonCommandQueue_ConfiguredServices_ResolvesCommandQueue()
    {
        var services = new ServiceCollection();
        services.AddMarshaller<JsonMarshaller>();
        services.AddAmazonCommandQueue(o =>
        {
            o.Credentials = new AnonymousAWSCredentials();
            o.Endpoint = RegionEndpoint.EUWest1;
            o.SourceQueue = new Uri("urn:orders:queue");
        });
        var provider = services.BuildServiceProvider();

        var queue = provider.GetRequiredService<IPointToPointChannel<ICommand>>();

        TestOutput.WriteLine(queue.GetType().FullName);
        Assert.IsType<AmazonCommandQueue>(queue);
    }
}

Savvyio.Extensions.Text.Json

using System;
using Codebelt.Extensions.Xunit;
using Savvyio.Commands;
using Savvyio.Commands.Messaging;
using Savvyio.Extensions.Text.Json;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class TextJsonExample : Test
{
    public TextJsonExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void SerializeAndDeserialize_MessageEnvelope_RoundTripsCommandPayload()
    {
        var original = new ShipOrder("SO-42")
            .ToMessage(new Uri("urn:sales:orders"), nameof(ShipOrder), o => o.MessageId = "msg-order-42");

        using var payload = JsonMarshaller.Default.Serialize(original);
        var rehydrated = JsonMarshaller.Default.Deserialize<IMessage<ShipOrder>>(payload);

        TestOutput.WriteLine(rehydrated.Id);
        Assert.Equal(original.Id, rehydrated.Id);
        Assert.Equal(original.Data.OrderNumber, rehydrated.Data.OrderNumber);
        Assert.Equal(original.Type, rehydrated.Type);
    }

    private sealed record ShipOrder(string OrderNumber) : Command;
}

Savvyio.Extensions.DependencyInjection.Text.Json

using Codebelt.Extensions.Xunit;
using Cuemon.Extensions.Text.Json.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Savvyio.Extensions.DependencyInjection.Text.Json;
using Savvyio.Extensions.Text.Json;
using Xunit;

namespace MyProject.Tests;

public class DependencyInjectionTextJsonExample : Test
{
    public DependencyInjectionTextJsonExample(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void AddJsonMarshaller_CustomJsonSetup_RegistersFormatterOptionsAndMarshaller()
    {
        var services = new ServiceCollection();
        services.AddJsonMarshaller(o => o.Settings.WriteIndented = true);
        var provider = services.BuildServiceProvider();

        var marshaller = provider.GetRequiredService<JsonMarshaller>();
        var options = provider.GetRequiredService<JsonFormatterOptions>();

        TestOutput.WriteLine($"WriteIndented={options.Settings.WriteIndented}");
        Assert.NotNull(marshaller);
        Assert.True(options.Settings.WriteIndented);
    }
}

Savvyio.Messaging

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Savvyio;
using Savvyio.Messaging;
using Xunit;

namespace MyProject.Tests;

public class MessageAsyncEnumerableTests : Test
{
    public MessageAsyncEnumerableTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public async Task Enumerate_AcknowledgedMessage_CapturesProperties()
    {
        var envelope = new Message<OrderSubmitted>("msg-001", new Uri("urn:orders:checkout"), nameof(OrderSubmitted), new OrderSubmitted(Guid.NewGuid()));
        List<IDictionary<string, object>> acknowledged = new();

        var sut = new MessageAsyncEnumerable<OrderSubmitted>(new IMessage<OrderSubmitted>[] { envelope }, options =>
        {
            options.MessageCallback = async message =>
            {
                message.Properties["Stage"] = "validated";
                message.Properties["OrderId"] = message.Data.OrderId;
                await message.AcknowledgeAsync();
            };
            options.AcknowledgedPropertiesCallback = properties =>
            {
                acknowledged = properties.Select(p => (IDictionary<string, object>)new Dictionary<string, object>(p)).ToList();
                return Task.CompletedTask;
            };
        });

        await foreach (var message in sut) { TestOutput.WriteLine($"{message.Id} acknowledged from {message.Source}"); }

        var state = Assert.Single(acknowledged);
        Assert.Equal("validated", (string)state["Stage"]);
        Assert.Equal(envelope.Data.OrderId, (Guid)state["OrderId"]);
    }
}

public sealed record OrderSubmitted(Guid OrderId) : Request;

Savvyio.Queries

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon;
using Savvyio.Dispatchers;
using Savvyio.Handlers;
using Savvyio.Queries;
using Xunit;

namespace MyProject.Tests;

public class SavvyioQueriesTests : Test
{
    public SavvyioQueriesTests(ITestOutputHelper output) : base(output) { }

    [Fact]
    public void QueryDispatcher_ConfiguredHandler_ReturnsResponse()
    {
        var dispatcher = new QueryDispatcher(new ServiceLocator(serviceType =>
            serviceType == typeof(IQueryHandler)
                ? (IEnumerable<object>)new IQueryHandler[] { new InvoiceSummaryHandler() }
                : Array.Empty<object>()));

        var summary = dispatcher.Query(new GetInvoiceSummary(42));

        TestOutput.WriteLine($"Invoice {summary.InvoiceNumber} resolved as {summary.DocumentNumber}.");
        Assert.Equal(42, summary.InvoiceNumber);
        Assert.Equal("INV-0042", summary.DocumentNumber);
    }
}

public sealed record GetInvoiceSummary(int InvoiceNumber) : Query<InvoiceSummary>;

public sealed record InvoiceSummary(int InvoiceNumber, string DocumentNumber);

public sealed class InvoiceSummaryHandler : IQueryHandler
{
    public IRequestReplyActivator<IQuery> Delegates { get; } = new InvoiceSummaryActivator();
}

public sealed class InvoiceSummaryActivator : IRequestReplyActivator<IQuery>
{
    public bool TryInvoke<TResponse>(IQuery request, out TResponse result)
    {
        if (request is GetInvoiceSummary query && typeof(TResponse) == typeof(InvoiceSummary))
        {
            result = (TResponse)(object)new InvoiceSummary(query.InvoiceNumber, $"INV-{query.InvoiceNumber:D4}");
            return true;
        }

        result = default!;
        return false;
    }

    public Task<ConditionalValue<TResponse>> TryInvokeAsync<TResponse>(IQuery request, CancellationToken ct = default)
        => throw new NotSupportedException();
}

These examples work because Savvyio.App transitively references the packages named in each heading. Savvyio.App itself only contributes the single package reference; each API is owned by the referenced package shown above.

Installation

dotnet add package Savvyio.App

Usage guidance

Choose Savvyio.App when an application wants the combined Savvy I/O stack through one NuGet dependency, including commands, queries, aggregates, event sourcing, messaging, dependency injection registration, persistence adapters, JSON marshalling, and transport integrations. If you only need one slice of that stack, such as core commands, EF Core persistence, one serializer, or one transport adapter, the smaller referenced package is the better choice than the aggregate package.

Family packages