├── .github └── FUNDING.yml ├── test ├── SuperSafeBank.Service.Core.Persistence.Mongo.Tests │ ├── Usings.cs │ ├── appsettings.json │ ├── Integration │ │ ├── MongoFixture.cs │ │ └── CustomerEmailsServiceTests.cs │ └── SuperSafeBank.Service.Core.Persistence.Mongo.Tests.csproj ├── SuperSafeBank.Persistence.SQLServer.Tests │ ├── Usings.cs │ ├── appsettings.json │ ├── Unit │ │ └── AggregateTableCreatorTests.cs │ ├── Integration │ │ ├── AggregateTableCreatorTests.cs │ │ └── DbFixture.cs │ └── SuperSafeBank.Persistence.SQLServer.Tests.csproj ├── SuperSafeBank.Persistence.EventStore.Tests │ ├── appsettings.json │ ├── Integration │ │ ├── EventStoreFixture.cs │ │ └── EventStoreConnectionWrapperTests.cs │ └── SuperSafeBank.Persistence.EventStore.Tests.csproj ├── SuperSafeBank.Persistence.Azure.Tests │ ├── appsettings.json │ ├── Integration │ │ └── Fixtures │ │ │ └── StorageTableFixutre.cs │ └── SuperSafeBank.Persistence.Azure.Tests.csproj ├── SuperSafeBank.Service.Core.Azure.Tests │ ├── appsettings.json │ ├── SuperSafeBank.Service.Core.Azure.Tests.csproj │ └── Integration │ │ └── QueryHandlers │ │ ├── CustomersArchiveHandlerTests.cs │ │ ├── CustomerByIdHandlerTests.cs │ │ └── AccountByIdHandlerTests.cs ├── SuperSafeBank.Worker.Core.Azure.Tests │ ├── appsettings.json │ ├── SuperSafeBank.Worker.Core.Azure.Tests.csproj │ └── Integration │ │ └── EventHandlers │ │ └── CustomersArchiveHandlerTests.cs ├── SuperSafeBank.Service.Core.Tests │ ├── Fixtures │ │ ├── IConfigurationStrategy.cs │ │ ├── IQueryModelsSeeder.cs │ │ ├── AzureQueryModelsSeeder.cs │ │ ├── OnPremiseConfigurationStrategy.cs │ │ ├── OnPremiseQueryModelsSeeder.cs │ │ └── WebApiFixture.cs │ ├── appsettings.json │ ├── TestUtils.cs │ └── SuperSafeBank.Service.Core.Tests.csproj ├── SuperSafeBank.Persistence.Tests │ ├── Models │ │ ├── DummyEvent.cs │ │ └── DummyAggregate.cs │ └── SuperSafeBank.Persistence.Tests.csproj ├── SuperSafeBank.Domain.Tests │ ├── MoneyTests.cs │ └── SuperSafeBank.Domain.Tests.csproj └── SuperSafeBank.Persistence.EvenireDB.Tests │ └── SuperSafeBank.Persistence.EvenireDB.Tests.csproj ├── src ├── SuperSafeBank.Worker.Core │ ├── InfrastructureConfig.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Registries │ │ ├── EventConsumerRegistry.cs │ │ └── InfrastructureRegistry.cs │ ├── appsettings.json │ ├── SuperSafeBank.Worker.Core.csproj │ ├── EventsConsumerWorker.cs │ └── Program.cs ├── SuperSafeBank.Common │ ├── Models │ │ ├── IEntity.cs │ │ ├── IDomainEvent.cs │ │ ├── IAggregateRoot.cs │ │ ├── BaseEntity.cs │ │ ├── BaseDomainEvent.cs │ │ └── BaseAggregateRoot.cs │ ├── EventBus │ │ ├── IIntegrationEvent.cs │ │ ├── IEventProducer.cs │ │ └── IEventConsumer.cs │ ├── IEventSerializer.cs │ ├── JsonSerializerDefaultOptions.cs │ ├── IAggregateRepository.cs │ ├── SuperSafeBank.Common.csproj │ ├── ValidationException.cs │ ├── ValidationError.cs │ ├── PrivateSetterContractResolver.cs │ └── JsonEventSerializer.cs ├── SuperSafeBank.Service.Core.Azure │ ├── Properties │ │ ├── launchSettings.json │ │ ├── serviceDependencies.json │ │ └── serviceDependencies.local.json │ ├── DTOs │ │ ├── CreateCustomerDto.cs │ │ └── CreateAccountDto.cs │ ├── host.json │ ├── QueryHandlers │ │ ├── CustomersArchiveHandler.cs │ │ ├── AccountByIdHandler.cs │ │ └── CustomerByIdHandler.cs │ ├── SuperSafeBank.Service.Core.Azure.csproj │ ├── Services │ │ └── CustomerEmailsService.cs │ ├── Program.cs │ └── Triggers │ │ └── CustomerTriggers.cs ├── SuperSafeBank.Service.Core.Persistence.SQLServer │ ├── CustomerEmail.cs │ ├── SuperSafeBank.Service.Core.Persistence.SQLServer.csproj │ ├── CustomerDbContext.cs │ └── SQLCustomerEmailsService.cs ├── SuperSafeBank.Domain │ ├── Services │ │ ├── ICurrencyConverter.cs │ │ ├── FakeCurrencyConverter.cs │ │ └── ICustomerEmailsService.cs │ ├── AccountTransactionException.cs │ ├── SuperSafeBank.Domain.csproj │ ├── IntegrationEvents │ │ ├── AccountCreated.cs │ │ ├── CustomerCreated.cs │ │ └── TransactionHappened.cs │ ├── Email.cs │ ├── DomainEvents │ │ ├── CustomerEvents.cs │ │ └── AccountEvents.cs │ ├── Currency.cs │ ├── Commands │ │ ├── Deposit.cs │ │ ├── Withdraw.cs │ │ ├── CreateAccount.cs │ │ └── CreateCustomer.cs │ ├── Money.cs │ └── Customer.cs ├── SuperSafeBank.Worker.Core.Azure │ ├── Properties │ │ ├── launchSettings.json │ │ ├── serviceDependencies.json │ │ └── serviceDependencies.local.json │ ├── host.json │ ├── Triggers │ │ └── Triggers.cs │ ├── SuperSafeBank.Worker.Core.Azure.csproj │ ├── Program.cs │ └── EventHandlers │ │ └── CustomersArchiveHandler.cs ├── SuperSafeBank.Worker.Notifications │ ├── Properties │ │ └── launchSettings.json │ ├── INotificationsService.cs │ ├── INotificationsFactory.cs │ ├── ApiClients │ │ ├── Models │ │ │ ├── CustomerDetails.cs │ │ │ └── AccountDetails.cs │ │ ├── ICustomersApiClient.cs │ │ ├── IAccountsApiClient.cs │ │ ├── AccountsApiClient.cs │ │ ├── CustomersApiClient.cs │ │ └── HttpClientPolicies.cs │ ├── appsettings.json │ ├── Notification.cs │ ├── FakeNotificationsService.cs │ ├── SuperSafeBank.Worker.Notifications.csproj │ ├── NotificationsFactory.cs │ └── AccountEventsWorker.cs ├── SuperSafeBank.Persistence.EventStore │ ├── IEventStoreConnectionWrapper.cs │ ├── SuperSafeBank.Persistence.EventStore.csproj │ └── IServiceCollectionExtensions.cs ├── SuperSafeBank.Service.Core.Azure.Common │ ├── Persistence │ │ ├── IViewsContext.cs │ │ ├── ViewsContext.cs │ │ └── ViewTableEntity.cs │ └── SuperSafeBank.Service.Core.Azure.Common.csproj ├── SuperSafeBank.Service.Core │ ├── DTOs │ │ ├── DepositDto.cs │ │ ├── WithdrawDto.cs │ │ ├── CreateAccountDto.cs │ │ └── CreateCustomerDto.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.json │ ├── Program.cs │ ├── SuperSafeBank.Service.Core.csproj │ └── Controllers │ │ └── CustomersController.cs ├── SuperSafeBank.Transport.Kafka │ ├── GuidDeserializer.cs │ ├── KeyDeserializerFactory.cs │ ├── KeySerializer.cs │ ├── IServiceCollectionExtensions.cs │ ├── SuperSafeBank.Transport.Kafka.csproj │ ├── KafkaProducerConfig.cs │ ├── EventsConsumerConfig.cs │ └── EventProducer.cs ├── SuperSafeBank.Persistence.SQLServer │ ├── IAggregateTableCreator.cs │ ├── SqlConnectionStringProvider.cs │ ├── ByteArrayTypeHandler.cs │ ├── SuperSafeBank.Persistence.SQLServer.csproj │ ├── IServiceCollectionExtensions.cs │ └── AggregateEvent.cs ├── SuperSafeBank.Service.Core.Persistence.EventStore │ ├── SuperSafeBank.Service.Core.Persistence.EventStore.csproj │ ├── CustomerEmailEvents.cs │ ├── EventStoreCustomerEmailsService.cs │ └── CustomerEmail.cs ├── SuperSafeBank.Service.Core.Persistence.Mongo │ ├── IQueryDbContext.cs │ ├── SuperSafeBank.Service.Core.Persistence.Mongo.csproj │ ├── QueryHandlers │ │ ├── AccountByIdHandler.cs │ │ ├── CustomerByIdHandler.cs │ │ └── CustomersArchiveHandler.cs │ ├── EventHandlers │ │ └── CustomersArchiveHandler.cs │ ├── CustomerEmailsService.cs │ └── IServiceCollectionExtensions.cs ├── SuperSafeBank.Persistence.Azure │ ├── SuperSafeBank.Persistence.Azure.csproj │ ├── IServiceCollectionExtensions.cs │ └── EventData.cs ├── SuperSafeBank.Service.Core.Common │ ├── SuperSafeBank.Service.Core.Common.csproj │ ├── Queries │ │ ├── CustomersArchive.cs │ │ ├── AccountById.cs │ │ └── CustomerById.cs │ ├── IImplementationTypeSelectorExtensions.cs │ └── EventHandlers │ │ └── RetryDecorator.cs ├── SuperSafeBank.Persistence.EvenireDB │ ├── IServiceCollectionExtensions.cs │ ├── SuperSafeBank.Persistence.EvenireDB.csproj │ └── AggregateRepository.cs └── SuperSafeBank.Transport.Azure │ ├── SuperSafeBank.Transport.Azure.csproj │ ├── IServiceCollectionExtensions.cs │ └── EventProducer.cs ├── .gitignore ├── .vscode ├── tasks.json └── launch.json ├── .circleci └── config.yml ├── readme.md └── docker-compose.yml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mizrael] 4 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Persistence.Mongo.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using NSubstitute; 2 | global using FluentAssertions; 3 | global using Xunit; -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/InfrastructureConfig.cs: -------------------------------------------------------------------------------- 1 | record InfrastructureConfig(string EventBus, string AggregateStore, string QueryDb); 2 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.EventStore.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "eventStore": "tcp://admin:changeit@127.0.0.1:1113" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/IEntity.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Common.Models 2 | { 3 | public interface IEntity 4 | { 5 | TKey Id { get; } 6 | } 7 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Persistence.Mongo.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "mongo": "mongodb://root:password@127.0.0.1:27017" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SuperSafeBank.Service.Core.Azure": { 4 | "commandName": "Project" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Azure.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "storageTable": "UseDevelopmentStorage=true" 4 | }, 5 | "tablePrefix": "tests" 6 | } 7 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Azure.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "QueryModelsStorage": "UseDevelopmentStorage=true" 4 | }, 5 | "tablePrefix": "tests" 6 | } 7 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.SQLServer/CustomerEmail.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Service.Core.Persistence.SQLServer 2 | { 3 | public record CustomerEmail(Guid CustomerId, string Email); 4 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/DTOs/CreateCustomerDto.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Service.Core.Azure.DTOs 2 | { 3 | public record CreateCustomerDto(string FirstName, string LastName, string Email); 4 | } 5 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/EventBus/IIntegrationEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Common.EventBus 4 | { 5 | public interface IIntegrationEvent 6 | { 7 | Guid Id { get; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Services/ICurrencyConverter.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Domain.Services 2 | { 3 | public interface ICurrencyConverter 4 | { 5 | Money Convert(Money amount, Currency currency); 6 | } 7 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SuperSafeBank.Worker.Core.Azure": { 4 | "commandName": "Project", 5 | "commandLineArgs": "host start --port 8071" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SuperSafeBank.Worker.Notifications": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--environment Development" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "sql": "Server=localhost;Database=SuperSafeBankTests_{0};User=sa;Password=Sup3r_Lam3_P4ss;Trust Server Certificate=true;" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SuperSafeBank.Worker.Notifications": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--environment Development" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Worker.Core.Azure.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "QueryModelsStorage": "UseDevelopmentStorage=true", 4 | "EventsStorage": "UseDevelopmentStorage=true" 5 | }, 6 | "tablePrefix": "tests" 7 | } 8 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/INotificationsService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace SuperSafeBank.Worker.Notifications 4 | { 5 | public interface INotificationsService 6 | { 7 | Task DispatchAsync(Notification notification); 8 | } 9 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Properties/serviceDependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights" 5 | }, 6 | "storage1": { 7 | "type": "storage", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/Properties/serviceDependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights" 5 | }, 6 | "storage1": { 7 | "type": "storage", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/IDomainEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Common.Models 4 | { 5 | public interface IDomainEvent 6 | { 7 | long AggregateVersion { get; } 8 | TKey AggregateId { get; } 9 | DateTime When { get; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/Properties/serviceDependencies.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights.sdk" 5 | }, 6 | "storage1": { 7 | "type": "storage.emulator", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Properties/serviceDependencies.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights.sdk" 5 | }, 6 | "storage1": { 7 | "type": "storage.emulator", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/EventBus/IEventProducer.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace SuperSafeBank.Common.EventBus 5 | { 6 | public interface IEventProducer 7 | { 8 | Task DispatchAsync(IIntegrationEvent @event, CancellationToken cancellationToken = default); 9 | } 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/IEventSerializer.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common.Models; 2 | using System; 3 | 4 | namespace SuperSafeBank.Common; 5 | 6 | public interface IEventSerializer 7 | { 8 | IDomainEvent Deserialize(string type, ReadOnlySpan data); 9 | byte[] Serialize(IDomainEvent @event); 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EventStore/IEventStoreConnectionWrapper.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using EventStore.ClientAPI; 3 | 4 | namespace SuperSafeBank.Persistence.EventStore 5 | { 6 | public interface IEventStoreConnectionWrapper 7 | { 8 | Task GetConnectionAsync(); 9 | } 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/IAggregateRoot.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace SuperSafeBank.Common.Models 4 | { 5 | public interface IAggregateRoot : IEntity 6 | { 7 | long Version { get; } 8 | IReadOnlyCollection> Events { get; } 9 | void ClearEvents(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/JsonSerializerDefaultOptions.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Common 2 | { 3 | public static class JsonSerializerDefaultOptions 4 | { 5 | public static readonly System.Text.Json.JsonSerializerOptions Defaults = new() 6 | { 7 | PropertyNameCaseInsensitive = true, 8 | }; 9 | } 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure.Common/Persistence/IViewsContext.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | 3 | namespace SuperSafeBank.Service.Core.Azure.Common.Persistence 4 | { 5 | public interface IViewsContext 6 | { 7 | TableClient Accounts { get; } 8 | TableClient CustomersArchive { get; } 9 | TableClient CustomersDetails { get; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/DTOs/CreateAccountDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Service.Core.Azure.DTOs 4 | { 5 | public record CreateAccountDto(Guid CustomerId, string CurrencyCode); 6 | 7 | public record DepositDto(string CurrencyCode, decimal Amount); 8 | 9 | public record WithdrawDto(string CurrencyCode, decimal Amount); 10 | } 11 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Services/FakeCurrencyConverter.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Domain.Services 2 | { 3 | public class FakeCurrencyConverter : ICurrencyConverter 4 | { 5 | public Money Convert(Money amount, Currency currency) 6 | { 7 | return amount.Currency == currency ? amount : new Money(currency, amount.Value); 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/DTOs/DepositDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SuperSafeBank.Service.Core.DTOs 4 | { 5 | public class DepositDto 6 | { 7 | [Required] 8 | public string CurrencyCode { get; set; } 9 | 10 | [Required, Range(0, double.MaxValue)] 11 | public decimal Amount { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/DTOs/WithdrawDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SuperSafeBank.Service.Core.DTOs 4 | { 5 | public class WithdrawDto 6 | { 7 | [Required] 8 | public string CurrencyCode { get; set; } 9 | 10 | [Required, Range(0, double.MaxValue)] 11 | public decimal Amount { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/BaseEntity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace SuperSafeBank.Common.Models 5 | { 6 | public abstract record BaseEntity : IEntity 7 | { 8 | protected BaseEntity() { } 9 | 10 | protected BaseEntity(TKey id) => Id = id; 11 | 12 | public TKey Id { get; protected set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/DTOs/CreateAccountDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace SuperSafeBank.Service.Core.DTOs 5 | { 6 | public class CreateAccountDto 7 | { 8 | [Required] 9 | public string CurrencyCode { get; set; } 10 | 11 | [Required] 12 | public Guid CustomerId { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/AccountTransactionException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Domain 4 | { 5 | public class AccountTransactionException : Exception 6 | { 7 | public Account Account { get; } 8 | 9 | public AccountTransactionException(string s, Account account) : base(s) 10 | { 11 | Account = account; 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/SuperSafeBank.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/IConfigurationStrategy.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | namespace SuperSafeBank.Service.Core.Tests.Fixtures 4 | { 5 | internal interface IConfigurationStrategy 6 | { 7 | void OnConfigureAppConfiguration(IConfigurationBuilder configurationBuilder); 8 | 9 | IQueryModelsSeeder CreateSeeder(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/GuidDeserializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Confluent.Kafka; 3 | 4 | namespace SuperSafeBank.Transport.Kafka 5 | { 6 | internal class GuidDeserializer : IDeserializer 7 | { 8 | public Guid Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context) 9 | { 10 | return new Guid(data); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/INotificationsFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace SuperSafeBank.Worker.Notifications 5 | { 6 | public interface INotificationsFactory 7 | { 8 | Task CreateNewAccountNotificationAsync(Guid accountId); 9 | Task CreateTransactionNotificationAsync(Guid accountId); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/Registries/EventConsumerRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace SuperSafeBank.Worker.Core.Registries 4 | { 5 | public static class EventConsumerRegistry 6 | { 7 | public static IServiceCollection RegisterWorker(this IServiceCollection services) 8 | => services.AddHostedService(); 9 | } 10 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/Models/CustomerDetails.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Worker.Notifications.ApiClients.Models 4 | { 5 | public record CustomerDetails 6 | { 7 | public Guid Id { get; init; } 8 | public string Firstname { get; init; } 9 | public string Lastname { get; init; } 10 | public string Email { get; init; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/DTOs/CreateCustomerDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SuperSafeBank.Service.Core.DTOs 4 | { 5 | public class CreateCustomerDto 6 | { 7 | [Required] 8 | public string FirstName { get; set; } 9 | 10 | [Required] 11 | public string LastName { get; set; } 12 | 13 | [Required] 14 | public string Email { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/ICustomersApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using SuperSafeBank.Worker.Notifications.ApiClients.Models; 5 | 6 | namespace SuperSafeBank.Worker.Notifications.ApiClients 7 | { 8 | public interface ICustomersApiClient { 9 | Task GetCustomerAsync(Guid customerId, CancellationToken cancellationToken = default); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/IAccountsApiClient.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Worker.Notifications.ApiClients.Models; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace SuperSafeBank.Worker.Notifications.ApiClients 7 | { 8 | public interface IAccountsApiClient 9 | { 10 | Task GetAccountAsync(Guid accountId, CancellationToken cancellationToken = default); 11 | } 12 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Services/ICustomerEmailsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace SuperSafeBank.Domain.Services 6 | { 7 | public interface ICustomerEmailsService 8 | { 9 | Task ExistsAsync(string email, CancellationToken cancellationToken = default); 10 | Task CreateAsync(string email, Guid customerId, CancellationToken cancellationToken = default); 11 | } 12 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/IQueryModelsSeeder.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace SuperSafeBank.Service.Core.Tests.Fixtures 4 | { 5 | public interface IQueryModelsSeeder 6 | { 7 | Task CreateCustomerDetails(Common.Queries.CustomerDetails model); 8 | Task CreateCustomerArchiveItem(Common.Queries.CustomerArchiveItem model); 9 | Task CreateAccountDetails(Common.Queries.AccountDetails model); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/Models/AccountDetails.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Worker.Notifications.ApiClients.Models 4 | { 5 | public record AccountDetails 6 | { 7 | public Guid Id { get; init; } 8 | public Guid OwnerId { get; init; } 9 | public string OwnerFirstName { get; init; } 10 | public string OwnerLastName { get; init; } 11 | public string OwnerEmail { get; init; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/IAggregateRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using SuperSafeBank.Common.Models; 4 | 5 | namespace SuperSafeBank.Common; 6 | 7 | public interface IAggregateRepository 8 | where TA : class, IAggregateRoot 9 | { 10 | Task PersistAsync(TA aggregateRoot, CancellationToken cancellationToken = default); 11 | Task RehydrateAsync(TKey key, CancellationToken cancellationToken = default); 12 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/IAggregateTableCreator.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common.Models; 2 | 3 | namespace SuperSafeBank.Persistence.SQLServer 4 | { 5 | public interface IAggregateTableCreator 6 | { 7 | Task EnsureTableAsync(CancellationToken cancellationToken = default) 8 | where TA : class, IAggregateRoot; 9 | 10 | string GetTableName() 11 | where TA : class, IAggregateRoot; 12 | } 13 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.EventStore/SuperSafeBank.Service.Core.Persistence.EventStore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/IQueryDbContext.cs: -------------------------------------------------------------------------------- 1 | using MongoDB.Driver; 2 | using SuperSafeBank.Service.Core.Common.Queries; 3 | 4 | namespace SuperSafeBank.Service.Core.Persistence.Mongo 5 | { 6 | public interface IQueryDbContext 7 | { 8 | IMongoCollection AccountsDetails { get; } 9 | IMongoCollection CustomersDetails { get; } 10 | IMongoCollection Customers { get; } 11 | } 12 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Tests/Models/DummyEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using SuperSafeBank.Common.Models; 3 | 4 | namespace SuperSafeBank.Persistence.Tests.Models 5 | { 6 | public record DummyEvent : BaseDomainEvent 7 | { 8 | private DummyEvent() { } 9 | public DummyEvent(DummyAggregate aggregate, string type) : base(aggregate) 10 | { 11 | Type = type; 12 | } 13 | 14 | public string Type { get; private set; } 15 | } 16 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/KeyDeserializerFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Confluent.Kafka; 3 | 4 | namespace SuperSafeBank.Transport.Kafka 5 | { 6 | internal class KeyDeserializerFactory 7 | { 8 | public IDeserializer Create() 9 | { 10 | var tk = typeof(TKey); 11 | if (tk == typeof(Guid)) 12 | return (dynamic)new GuidDeserializer(); 13 | throw new ArgumentOutOfRangeException($"invalid type: {tk}"); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.*~ 3 | project.lock.json 4 | .DS_Store 5 | *.pyc 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | msbuild.log 25 | msbuild.err 26 | msbuild.wrn 27 | 28 | # Visual Studio 2015 29 | .vs/ 30 | 31 | 32 | # Nuget 33 | packages/ 34 | node_modules/ 35 | dist/ 36 | TestResults/ 37 | coverage.opencover.xml -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "kafka": "127.0.0.1:9092", 4 | "loki": "http://127.0.0.1:3100" 5 | }, 6 | "eventsTopicName": "events", 7 | "eventsTopicGroupName": "notifications-worker-events-consumer", 8 | "CustomersApi": "https://localhost:5001", 9 | "AccountsApi": "https://localhost:5001", 10 | "Logging": { 11 | "LogLevel": { 12 | "Default": "Warning", 13 | "SuperSafeBank": "Information" 14 | } 15 | }, 16 | "AllowedHosts": "*" 17 | } 18 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.Azure/SuperSafeBank.Persistence.Azure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release;DebugAzure 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/IntegrationEvents/AccountCreated.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using SuperSafeBank.Common.EventBus; 3 | using System; 4 | 5 | namespace SuperSafeBank.Domain.IntegrationEvents 6 | { 7 | public record AccountCreated : IIntegrationEvent, INotification 8 | { 9 | public AccountCreated(Guid id, Guid accountId) 10 | { 11 | this.Id = id; 12 | this.AccountId = accountId; 13 | } 14 | 15 | public Guid AccountId { get; init; } 16 | public Guid Id { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/IntegrationEvents/CustomerCreated.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using SuperSafeBank.Common.EventBus; 3 | using System; 4 | 5 | namespace SuperSafeBank.Domain.IntegrationEvents 6 | { 7 | public record CustomerCreated : IIntegrationEvent, INotification 8 | { 9 | public CustomerCreated(Guid id, Guid customerId) 10 | { 11 | this.Id = id; 12 | this.CustomerId = customerId; 13 | } 14 | 15 | public Guid Id { get; } 16 | public Guid CustomerId { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/IntegrationEvents/TransactionHappened.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using SuperSafeBank.Common.EventBus; 3 | using System; 4 | 5 | namespace SuperSafeBank.Domain.IntegrationEvents 6 | { 7 | public record TransactionHappened : IIntegrationEvent, INotification 8 | { 9 | public TransactionHappened(Guid id, Guid accountId) 10 | { 11 | this.Id = id; 12 | this.AccountId = accountId; 13 | } 14 | 15 | public Guid AccountId { get; init; } 16 | public Guid Id { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/SuperSafeBank.Service.Core.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/SuperSafeBank.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/KeySerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Text.Json; 4 | using Confluent.Kafka; 5 | 6 | namespace SuperSafeBank.Transport.Kafka 7 | { 8 | internal class KeySerializer : ISerializer 9 | { 10 | public byte[] Serialize(TKey data, SerializationContext context) 11 | { 12 | if(data is Guid g) 13 | return g.ToByteArray(); 14 | var json = JsonSerializer.Serialize(data); 15 | return Encoding.UTF8.GetBytes(json); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure.Common/SuperSafeBank.Service.Core.Azure.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/EventBus/IEventConsumer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace SuperSafeBank.Common.EventBus 6 | { 7 | public interface IEventConsumer 8 | { 9 | Task StartConsumeAsync(CancellationToken cancellationToken = default); 10 | 11 | event EventReceivedHandler EventReceived; 12 | event ExceptionThrownHandler ExceptionThrown; 13 | } 14 | 15 | public delegate Task EventReceivedHandler(object sender, IIntegrationEvent @event); 16 | public delegate void ExceptionThrownHandler(object sender, Exception e); 17 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/SqlConnectionStringProvider.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Persistence.SQLServer 2 | { 3 | public class SqlConnectionStringProvider 4 | { 5 | public string ConnectionString { get; } 6 | 7 | public SqlConnectionStringProvider(string connectionString) 8 | { 9 | if (string.IsNullOrWhiteSpace(connectionString)) 10 | throw new ArgumentException($"'{nameof(connectionString)}' cannot be null or whitespace.", nameof(connectionString)); 11 | 12 | ConnectionString = connectionString; 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/ValidationException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace SuperSafeBank.Common 6 | { 7 | public class ValidationException : Exception 8 | { 9 | public ValidationException(string message) : this(message, null) { } 10 | public ValidationException(string message, params ValidationError[] errors) : base(message, null) 11 | { 12 | this.Errors = errors ?? Enumerable.Empty(); 13 | } 14 | 15 | public IEnumerable Errors { get; private set; } 16 | 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/ByteArrayTypeHandler.cs: -------------------------------------------------------------------------------- 1 | using Dapper; 2 | using System.Data; 3 | using System.Text; 4 | 5 | namespace SuperSafeBank.Persistence.SQLServer 6 | { 7 | public class ByteArrayTypeHandler : SqlMapper.TypeHandler 8 | { 9 | public override void SetValue(IDbDataParameter parameter, byte[] value) 10 | { 11 | parameter.Value = Encoding.UTF8.GetString(value, 0, value.Length); 12 | } 13 | 14 | public override byte[] Parse(object value) 15 | { 16 | return Encoding.UTF8.GetBytes((string)value); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Email.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Domain 4 | { 5 | public record Email 6 | { 7 | public Email(string value) 8 | { 9 | if(string.IsNullOrWhiteSpace(value)) 10 | throw new ArgumentNullException(nameof(value)); 11 | if (!value.Contains('@')) 12 | throw new ArgumentException($"invalid email address: '{value}'", nameof(value)); 13 | this.Value = value; 14 | } 15 | public string Value { get; } 16 | 17 | public override string ToString() 18 | => this.Value; 19 | } 20 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EventStore/SuperSafeBank.Persistence.EventStore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/SuperSafeBank.Persistence.SQLServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/Unit/AggregateTableCreatorTests.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Domain; 2 | 3 | namespace SuperSafeBank.Persistence.SQLServer.Tests.Unit 4 | { 5 | public class AggregateTableCreatorTests 6 | { 7 | [Fact] 8 | public void GetTableName_should_return_valid_table_name() 9 | { 10 | var dbConn = new SqlConnectionStringProvider("lorem"); 11 | var sut = new AggregateTableCreator(dbConn, "testDbo"); 12 | var table = sut.GetTableName(); 13 | table.Should().Be("testdbo.customer"); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.EventStore.Tests/Integration/EventStoreFixture.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | namespace SuperSafeBank.Persistence.EventStore.Tests.Integration 4 | { 5 | public class EventStoreFixture 6 | { 7 | public string ConnectionString { get; } 8 | 9 | public EventStoreFixture() 10 | { 11 | var configuration = new ConfigurationBuilder() 12 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 13 | .Build(); 14 | 15 | ConnectionString = configuration.GetConnectionString("eventStore"); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Common.EventBus; 4 | using SuperSafeBank.Common.Models; 5 | 6 | namespace SuperSafeBank.Transport.Kafka 7 | { 8 | public static class IServiceCollectionExtensions 9 | { 10 | public static IServiceCollection AddKafkaTransport(this IServiceCollection services, KafkaProducerConfig configuration) 11 | { 12 | return services.AddSingleton(configuration) 13 | .AddSingleton(); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EvenireDB/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using EvenireDB.Client; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SuperSafeBank.Common; 4 | 5 | namespace SuperSafeBank.Persistence.EvenireDB; 6 | 7 | public static class IServiceCollectionExtensions 8 | { 9 | public static IServiceCollection AddEvenireDBPersistence(this IServiceCollection services, EvenireClientConfig config) 10 | { 11 | ArgumentNullException.ThrowIfNull(config); 12 | 13 | return services.AddEvenireDB(config) 14 | .AddSingleton(typeof(IAggregateRepository<,>), typeof(AggregateRepository<,>)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "eventstore": "tcp://admin:changeit@127.0.0.1:1113", 4 | "kafka": "127.0.0.1:9092", 5 | "mongo": "mongodb://root:password@127.0.0.1:27017" 6 | }, 7 | "eventsTopicGroupName": "web-api-tests-events-consumer", 8 | 9 | "Infrastructure": { 10 | "EventBus": "Kafka", 11 | "AggregateStore": "EventStore", 12 | "QueryDb": "MongoDb" 13 | }, 14 | 15 | "Logging": { 16 | "LogLevel": { 17 | "Default": "Information", 18 | "Microsoft": "Warning", 19 | "Microsoft.Hosting.Lifetime": "Information" 20 | } 21 | }, 22 | "AllowedHosts": "*" 23 | } 24 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/ValidationError.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Common 4 | { 5 | public class ValidationError 6 | { 7 | public ValidationError(string context, string message) 8 | { 9 | if (string.IsNullOrWhiteSpace(context)) 10 | throw new ArgumentNullException(nameof(context)); 11 | if (string.IsNullOrWhiteSpace(message)) 12 | throw new ArgumentNullException(nameof(message)); 13 | 14 | this.Message = message; 15 | this.Context = context; 16 | } 17 | 18 | public string Context { get; } 19 | public string Message { get; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.SQLServer/SuperSafeBank.Service.Core.Persistence.SQLServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EvenireDB/SuperSafeBank.Persistence.EvenireDB.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/Queries/CustomersArchive.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace SuperSafeBank.Service.Core.Common.Queries 6 | { 7 | public record CustomerArchiveItem 8 | { 9 | public CustomerArchiveItem(Guid id, string firstname, string lastname) 10 | { 11 | Id = id; 12 | Firstname = firstname; 13 | Lastname = lastname; 14 | } 15 | 16 | public Guid Id { get; } 17 | public string Firstname { get; } 18 | public string Lastname { get; } 19 | } 20 | 21 | public class CustomersArchive : IRequest> { } 22 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/SuperSafeBank.Transport.Kafka.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | Debug;Release; 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:54031", 7 | "sslPort": 44316 8 | } 9 | }, 10 | "$schema": "http://json.schemastore.org/launchsettings.json", 11 | "profiles": { 12 | "SuperSafeBank.Web.API": { 13 | "commandName": "Project", 14 | "launchBrowser": true, 15 | "launchUrl": "customers", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | }, 19 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Azure/SuperSafeBank.Transport.Azure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release;DebugAzure 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/Notification.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Worker.Notifications 4 | { 5 | public class Notification 6 | { 7 | public Notification(string recipient, string message) 8 | { 9 | if (string.IsNullOrWhiteSpace(recipient)) 10 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(recipient)); 11 | if (string.IsNullOrWhiteSpace(message)) 12 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(message)); 13 | Recipient = recipient; 14 | Message = message; 15 | } 16 | public string Recipient { get; } 17 | public string Message { get; } 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "loki": "http://127.0.0.1:3100", 4 | "eventstore": "tcp://admin:changeit@127.0.0.1:1113", 5 | "kafka": "127.0.0.1:9092", 6 | "mongo": "mongodb://root:password@127.0.0.1:27017", 7 | "sql": "Server=localhost;Database=SuperSafeBank;User=sa;Password=Sup3r_Lam3_P4ss;Trust Server Certificate=true;" 8 | }, 9 | 10 | "queryDbName": "bankAccountsQueries", 11 | "eventsTopicName": "events", 12 | 13 | "Infrastructure": { 14 | "EventBus": "Kafka", 15 | "AggregateStore": "SQLServer", 16 | "QueryDb": "MongoDb" 17 | }, 18 | 19 | "Logging": { 20 | "LogLevel": { 21 | "Default": "Warning", 22 | "SuperSafeBank": "Information" 23 | } 24 | }, 25 | "AllowedHosts": "*" 26 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.SQLServer/CustomerDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace SuperSafeBank.Service.Core.Persistence.SQLServer 4 | { 5 | public class CustomerDbContext : DbContext 6 | { 7 | public CustomerDbContext(DbContextOptions opts) : base(opts) 8 | { 9 | Database.EnsureCreated(); 10 | } 11 | 12 | protected override void OnModelCreating(ModelBuilder modelBuilder) 13 | { 14 | modelBuilder.Entity(builder => 15 | { 16 | builder.HasKey(e => new { e.CustomerId, e.Email}); 17 | }); 18 | 19 | base.OnModelCreating(modelBuilder); 20 | } 21 | 22 | public DbSet CustomerEmails { get; set; } 23 | } 24 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Dapper; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SuperSafeBank.Common; 4 | 5 | namespace SuperSafeBank.Persistence.SQLServer 6 | { 7 | public static class IServiceCollectionExtensions 8 | { 9 | public static IServiceCollection AddSQLServerPersistence(this IServiceCollection services, string connectionString) 10 | { 11 | SqlMapper.AddTypeHandler(new ByteArrayTypeHandler()); 12 | 13 | return services.AddSingleton(new SqlConnectionStringProvider(connectionString)) 14 | .AddSingleton() 15 | .AddSingleton(typeof(IAggregateRepository<,>), typeof(SQLAggregateRepository<,>)); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "loki": "http://127.0.0.1:3100", 4 | "eventstore": "tcp://admin:changeit@127.0.0.1:1113", 5 | "kafka": "127.0.0.1:9092", 6 | "mongo": "mongodb://root:password@127.0.0.1:27017", 7 | "sql": "Server=localhost;Database=SuperSafeBank;User=sa;Password=Sup3r_Lam3_P4ss;Trust Server Certificate=true;" 8 | }, 9 | 10 | "queryDbName": "bankAccountsQueries", 11 | "eventsTopicName": "events", 12 | "eventsTopicGroupName": "core-service-consumer", 13 | 14 | "Infrastructure": { 15 | "EventBus": "Kafka", 16 | "AggregateStore": "SQLServer", 17 | "QueryDb": "MongoDb" 18 | }, 19 | 20 | "Logging": { 21 | "LogLevel": { 22 | "Default": "Warning", 23 | "SuperSafeBank": "Information" 24 | } 25 | }, 26 | "AllowedHosts": "*" 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/SuperSafeBank.Service.Core.Persistence.Mongo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/KafkaProducerConfig.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Transport.Kafka 2 | { 3 | public record KafkaProducerConfig 4 | { 5 | public KafkaProducerConfig(string kafkaConnectionString, string topicBaseName) 6 | { 7 | if (string.IsNullOrWhiteSpace(kafkaConnectionString)) 8 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(kafkaConnectionString)); 9 | if (string.IsNullOrWhiteSpace(topicBaseName)) 10 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(topicBaseName)); 11 | 12 | KafkaConnectionString = kafkaConnectionString; 13 | TopicName = topicBaseName; 14 | } 15 | 16 | public string KafkaConnectionString { get; } 17 | public string TopicName { get; } 18 | } 19 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Domain.Tests/MoneyTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Xunit; 3 | 4 | namespace SuperSafeBank.Domain.Tests 5 | { 6 | public class MoneyTests 7 | { 8 | [Fact] 9 | public void Add_should_throw_when_currencies_do_not_match_and_converter_null() 10 | { 11 | var sut = Money.Zero(Currency.CanadianDollar); 12 | var other = Money.Zero(Currency.Euro); 13 | Assert.Throws(() => sut.Add(other)); 14 | } 15 | 16 | [Fact] 17 | public void Subtract_should_throw_when_currencies_do_not_match_and_converter_null() 18 | { 19 | var sut = Money.Zero(Currency.CanadianDollar); 20 | var other = Money.Zero(Currency.Euro); 21 | Assert.Throws(() => sut.Subtract(other)); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.EventStore/CustomerEmailEvents.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common.Models; 2 | 3 | namespace SuperSafeBank.Service.Core.Persistence.EventStore 4 | { 5 | public static class CustomerEmailEvents 6 | { 7 | public record CustomerEmailCreated : BaseDomainEvent 8 | { 9 | /// 10 | /// for deserialization 11 | /// 12 | private CustomerEmailCreated() { } 13 | 14 | public CustomerEmailCreated(CustomerEmail customer, string email, Guid customerId) : base(customer) 15 | { 16 | Email = email; 17 | CustomerId = customerId; 18 | } 19 | 20 | public string Email { get; init; } 21 | public Guid CustomerId { get; init; } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EventStore/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Common; 4 | using System; 5 | 6 | namespace SuperSafeBank.Persistence.EventStore 7 | { 8 | public static class IServiceCollectionExtensions 9 | { 10 | public static IServiceCollection AddEventStorePersistence(this IServiceCollection services, string connectionString) 11 | { 12 | return services.AddSingleton(ctx => 13 | { 14 | var logger = ctx.GetRequiredService>(); 15 | return new EventStoreConnectionWrapper(new Uri(connectionString), logger); 16 | }).AddSingleton(typeof(IAggregateRepository<,>), typeof(EventStoreAggregateRepository<,>)); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "command": "dotnet", 9 | "type": "shell", 10 | "args": [ 11 | "build", 12 | "./src/SuperSafeBank.sln", 13 | // Ask dotnet build to generate full paths for file names. 14 | "/property:GenerateFullPaths=true", 15 | // Do not generate summary otherwise it leads to duplicate errors in Problems panel 16 | "/consoleloggerparameters:NoSummary" 17 | ], 18 | "group": "build", 19 | "presentation": { 20 | "reveal": "silent" 21 | }, 22 | "problemMatcher": "$msCompile" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/FakeNotificationsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace SuperSafeBank.Worker.Notifications 6 | { 7 | public class FakeNotificationsService : INotificationsService 8 | { 9 | private readonly ILogger _logger; 10 | 11 | public FakeNotificationsService(ILogger logger) 12 | { 13 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 14 | } 15 | 16 | public Task DispatchAsync(Notification notification) 17 | { 18 | if (notification == null) 19 | throw new ArgumentNullException(nameof(notification)); 20 | _logger.LogInformation("sending notification to {Recipient}", notification.Recipient); 21 | return Task.CompletedTask; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/QueryHandlers/AccountByIdHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using MediatR; 4 | using MongoDB.Driver; 5 | using SuperSafeBank.Service.Core.Common.Queries; 6 | 7 | namespace SuperSafeBank.Service.Core.Persistence.Mongo.QueryHandlers 8 | { 9 | public class AccountByIdHandler : IRequestHandler 10 | { 11 | private readonly IQueryDbContext _db; 12 | 13 | public AccountByIdHandler(IQueryDbContext db) 14 | { 15 | _db = db; 16 | } 17 | 18 | public async Task Handle(AccountById request, CancellationToken cancellationToken) 19 | { 20 | var cursor = await _db.AccountsDetails.FindAsync(c => c.Id == request.AccountId, 21 | null, cancellationToken); 22 | return await cursor.FirstOrDefaultAsync(cancellationToken); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/BaseDomainEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SuperSafeBank.Common.Models 4 | { 5 | public abstract record BaseDomainEvent : IDomainEvent 6 | where TA : IAggregateRoot 7 | { 8 | /// 9 | /// for deserialization 10 | /// 11 | protected BaseDomainEvent() { } 12 | 13 | protected BaseDomainEvent(TA aggregateRoot) 14 | { 15 | if(aggregateRoot is null) 16 | throw new ArgumentNullException(nameof(aggregateRoot)); 17 | 18 | this.AggregateVersion = aggregateRoot.Version; 19 | this.AggregateId = aggregateRoot.Id; 20 | this.When = DateTime.UtcNow; 21 | } 22 | 23 | public long AggregateVersion { get; private set; } 24 | public TKey AggregateId { get; private set; } 25 | public DateTime When { get; private set; } 26 | } 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/QueryHandlers/CustomerByIdHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using MediatR; 4 | using MongoDB.Driver; 5 | using SuperSafeBank.Service.Core.Common.Queries; 6 | 7 | namespace SuperSafeBank.Service.Core.Persistence.Mongo.QueryHandlers 8 | { 9 | public class CustomerByIdHandler : IRequestHandler 10 | { 11 | private readonly IQueryDbContext _db; 12 | 13 | public CustomerByIdHandler(IQueryDbContext db) 14 | { 15 | _db = db; 16 | } 17 | 18 | public async Task Handle(CustomerById request, CancellationToken cancellationToken) 19 | { 20 | var cursor = await _db.CustomersDetails.FindAsync(c => c.Id == request.CustomerId, 21 | null, cancellationToken); 22 | return await cursor.FirstOrDefaultAsync(cancellationToken); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/PrivateSetterContractResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Serialization; 4 | 5 | namespace SuperSafeBank.Common 6 | { 7 | /// 8 | /// https://www.mking.net/blog/working-with-private-setters-in-json-net 9 | /// 10 | public class PrivateSetterContractResolver : DefaultContractResolver 11 | { 12 | protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) 13 | { 14 | var jsonProperty = base.CreateProperty(member, memberSerialization); 15 | if (jsonProperty.Writable) 16 | return jsonProperty; 17 | 18 | if (member is PropertyInfo propertyInfo) 19 | { 20 | var setter = propertyInfo.GetSetMethod(true); 21 | jsonProperty.Writable = setter != null; 22 | } 23 | 24 | return jsonProperty; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/Queries/AccountById.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using MediatR; 3 | using SuperSafeBank.Domain; 4 | 5 | namespace SuperSafeBank.Service.Core.Common.Queries 6 | { 7 | public record AccountDetails 8 | { 9 | public AccountDetails(Guid id, Guid ownerId, string ownerFirstName, string ownerLastName, string ownerEmail, Money balance) 10 | { 11 | Id = id; 12 | OwnerId = ownerId; 13 | OwnerFirstName = ownerFirstName; 14 | OwnerLastName = ownerLastName; 15 | OwnerEmail = ownerEmail; 16 | Balance = balance; 17 | } 18 | 19 | public Guid Id { get; } 20 | public Guid OwnerId { get; } 21 | public string OwnerFirstName { get; } 22 | public string OwnerLastName { get; } 23 | public string OwnerEmail { get; } 24 | public Money Balance { get; } 25 | } 26 | 27 | public record AccountById(Guid AccountId) : IRequest; 28 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure.Common/Persistence/ViewsContext.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | 3 | namespace SuperSafeBank.Service.Core.Azure.Common.Persistence 4 | { 5 | public class ViewsContext : IViewsContext 6 | { 7 | public ViewsContext(string connectionString, string tablesPrefix) 8 | { 9 | this.CustomersDetails = new TableClient(connectionString, $"{tablesPrefix}{nameof(this.CustomersDetails)}"); 10 | this.CustomersDetails.CreateIfNotExists(); 11 | 12 | this.CustomersArchive = new TableClient(connectionString, $"{tablesPrefix}{nameof(this.CustomersArchive)}"); 13 | this.CustomersArchive.CreateIfNotExists(); 14 | 15 | this.Accounts = new TableClient(connectionString, $"{tablesPrefix}{nameof(this.Accounts)}"); 16 | this.Accounts.CreateIfNotExists(); 17 | } 18 | 19 | public TableClient CustomersDetails { get; } 20 | public TableClient CustomersArchive { get; } 21 | public TableClient Accounts { get; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Tests/Models/DummyAggregate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using SuperSafeBank.Common.Models; 5 | 6 | namespace SuperSafeBank.Persistence.Tests.Models 7 | { 8 | public record DummyAggregate : BaseAggregateRoot 9 | { 10 | private DummyAggregate() { } 11 | public DummyAggregate(Guid id) : base(id) 12 | { 13 | Append(new DummyEvent(this, "created")); 14 | } 15 | 16 | public void DoSomething(string what) => Append(new DummyEvent(this, what)); 17 | 18 | protected override void When(IDomainEvent @event) 19 | { 20 | Id = @event.AggregateId; 21 | 22 | if (@event is DummyEvent dummyEvent) 23 | _whatHappened.Add(dummyEvent.Type); 24 | } 25 | 26 | private readonly IList _whatHappened = new List(); 27 | public IReadOnlyCollection WhatHappened => _whatHappened.ToImmutableList(); 28 | } 29 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Tests/SuperSafeBank.Persistence.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/AzureQueryModelsSeeder.cs: -------------------------------------------------------------------------------- 1 | #if OnAzure 2 | using System.Threading.Tasks; 3 | using Microsoft.Azure.Cosmos; 4 | using SuperSafeBank.Web.Core.Queries.Models; 5 | 6 | namespace SuperSafeBank.Service.Core.Tests.Fixtures 7 | { 8 | public class AzureQueryModelsSeeder : IQueryModelsSeeder 9 | { 10 | private readonly Database _db; 11 | 12 | public AzureQueryModelsSeeder(Database cosmosClient) 13 | { 14 | _db = cosmosClient; 15 | } 16 | 17 | public async Task CreateAccountDetails(AccountDetails model) 18 | { 19 | await _db.GetContainer("AccountsDetails").CreateItemAsync(model); 20 | } 21 | 22 | public async Task CreateCustomerArchiveItem(CustomerArchiveItem model) 23 | { 24 | await _db.GetContainer("CustomersArchive").CreateItemAsync(model); 25 | } 26 | 27 | public async Task CreateCustomerDetails(CustomerDetails model) 28 | { 29 | await _db.GetContainer("CustomersDetails").CreateItemAsync(model); 30 | } 31 | } 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/IImplementationTypeSelectorExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Scrutor; 4 | using SuperSafeBank.Service.Core.Common.EventHandlers; 5 | 6 | namespace SuperSafeBank.Service.Core.Common 7 | { 8 | public static class IImplementationTypeSelectorExtensions 9 | { 10 | private static HashSet _decorators; 11 | 12 | static IImplementationTypeSelectorExtensions() 13 | { 14 | _decorators = new HashSet(new[] 15 | { 16 | typeof(RetryDecorator<>) 17 | }); 18 | } 19 | 20 | public static IImplementationTypeSelector RegisterHandlers(this IImplementationTypeSelector selector, Type type) 21 | { 22 | return selector.AddClasses(c => 23 | c.AssignableTo(type) 24 | .Where(t => !_decorators.Contains(t)) 25 | ) 26 | .UsingRegistrationStrategy(RegistrationStrategy.Append) 27 | .AsImplementedInterfaces() 28 | .WithScopedLifetime(); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/QueryHandlers/CustomersArchiveHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using MongoDB.Driver; 6 | using SuperSafeBank.Service.Core.Common.Queries; 7 | 8 | namespace SuperSafeBank.Service.Core.Persistence.Mongo.QueryHandlers 9 | { 10 | public class CustomersArchiveHandler : IRequestHandler> 11 | { 12 | private readonly IQueryDbContext _db; 13 | 14 | public CustomersArchiveHandler(IQueryDbContext db) 15 | { 16 | _db = db; 17 | } 18 | 19 | public async Task> Handle(CustomersArchive request, CancellationToken cancellationToken) 20 | { 21 | var filter = Builders.Filter.Empty; 22 | var cursor = await _db.Customers.FindAsync(filter, null, cancellationToken); 23 | IEnumerable results = await cursor.ToListAsync(cancellationToken); 24 | return results; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/AccountsApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using SuperSafeBank.Common; 7 | using SuperSafeBank.Worker.Notifications.ApiClients.Models; 8 | 9 | namespace SuperSafeBank.Worker.Notifications.ApiClients 10 | { 11 | public class AccountsApiClient : IAccountsApiClient 12 | { 13 | private readonly HttpClient _client; 14 | 15 | public AccountsApiClient(HttpClient client) 16 | { 17 | _client = client ?? throw new ArgumentNullException(nameof(client)); 18 | } 19 | 20 | public async Task GetAccountAsync(Guid accountId, CancellationToken cancellationToken = default) 21 | { 22 | using var response = await _client.GetStreamAsync($"accounts/{accountId}", cancellationToken); 23 | var result = await JsonSerializer.DeserializeAsync(response, JsonSerializerDefaultOptions.Defaults, cancellationToken: cancellationToken); 24 | return result; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Azure/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Azure.Messaging.ServiceBus; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using SuperSafeBank.Common.EventBus; 5 | 6 | namespace SuperSafeBank.Transport.Azure 7 | { 8 | public record EventProducerConfig(string ConnectionString, string TopicName); 9 | 10 | public static class IServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddAzureTransport(this IServiceCollection services, EventProducerConfig config) 13 | { 14 | return services.AddSingleton(ctx => 15 | { 16 | return new ServiceBusClient(config.ConnectionString); 17 | }).AddSingleton(ctx => 18 | { 19 | var clientFactory = ctx.GetRequiredService(); 20 | var logger = ctx.GetRequiredService>(); 21 | return new EventProducer(clientFactory, config.TopicName, logger); 22 | }); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/CustomersApiClient.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common; 2 | using SuperSafeBank.Worker.Notifications.ApiClients.Models; 3 | using System; 4 | using System.Net.Http; 5 | using System.Text.Json; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Worker.Notifications.ApiClients 10 | { 11 | public class CustomersApiClient : ICustomersApiClient 12 | { 13 | private readonly HttpClient _client; 14 | 15 | public CustomersApiClient(HttpClient client) 16 | { 17 | _client = client ?? throw new ArgumentNullException(nameof(client)); 18 | } 19 | 20 | public async Task GetCustomerAsync(Guid customerId, CancellationToken cancellationToken = default) 21 | { 22 | using var response = await _client.GetStreamAsync($"customers/{customerId}"); 23 | var result = await JsonSerializer.DeserializeAsync(response, JsonSerializerDefaultOptions.Defaults, cancellationToken: cancellationToken); 24 | return result; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.EventStore/EventStoreCustomerEmailsService.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common; 2 | using SuperSafeBank.Domain.Services; 3 | 4 | namespace SuperSafeBank.Service.Core.Persistence.EventStore 5 | { 6 | public class EventStoreCustomerEmailsService : ICustomerEmailsService 7 | { 8 | private readonly IAggregateRepository _customerEmailRepository; 9 | 10 | public EventStoreCustomerEmailsService(IAggregateRepository customerEmailRepository) 11 | { 12 | _customerEmailRepository = customerEmailRepository; 13 | } 14 | 15 | public Task CreateAsync(string email, Guid customerId, CancellationToken cancellationToken = default) 16 | => _customerEmailRepository.PersistAsync(new CustomerEmail(email, customerId), cancellationToken); 17 | 18 | public async Task ExistsAsync(string email, CancellationToken cancellationToken = default) 19 | { 20 | var aggregate = await _customerEmailRepository.RehydrateAsync(email, cancellationToken); 21 | return aggregate != null; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/EventsConsumerConfig.cs: -------------------------------------------------------------------------------- 1 | namespace SuperSafeBank.Transport.Kafka 2 | { 3 | public record EventsConsumerConfig 4 | { 5 | public EventsConsumerConfig(string kafkaConnectionString, string topicBaseName, string consumerGroup) 6 | { 7 | if (string.IsNullOrWhiteSpace(kafkaConnectionString)) 8 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(kafkaConnectionString)); 9 | if (string.IsNullOrWhiteSpace(topicBaseName)) 10 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(topicBaseName)); 11 | if (string.IsNullOrWhiteSpace(consumerGroup)) 12 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(consumerGroup)); 13 | 14 | KafkaConnectionString = kafkaConnectionString; 15 | TopicName = topicBaseName; 16 | ConsumerGroup = consumerGroup; 17 | } 18 | 19 | public string KafkaConnectionString { get; } 20 | public string TopicName { get; } 21 | public string ConsumerGroup { get; } 22 | } 23 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/ApiClients/HttpClientPolicies.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using Polly; 6 | using Polly.Extensions.Http; 7 | 8 | namespace SuperSafeBank.Worker.Notifications.ApiClients 9 | { 10 | public static class HttpClientPolicies 11 | { 12 | private static readonly HashSet RetryableCodes = new HashSet() 13 | { 14 | System.Net.HttpStatusCode.NotFound, 15 | System.Net.HttpStatusCode.UnprocessableEntity, 16 | System.Net.HttpStatusCode.InternalServerError, 17 | System.Net.HttpStatusCode.ServiceUnavailable, 18 | }; 19 | 20 | public static IAsyncPolicy GetRetryPolicy(int retryCount = 3) 21 | { 22 | return HttpPolicyExtensions 23 | .HandleTransientHttpError() 24 | .OrResult(msg => RetryableCodes.Contains(msg.StatusCode)) 25 | .WaitAndRetryAsync(retryCount, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/EventHandlers/RetryDecorator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using Polly; 6 | 7 | namespace SuperSafeBank.Service.Core.Common.EventHandlers 8 | { 9 | public class RetryDecorator : MediatR.INotificationHandler 10 | where TNotification : MediatR.INotification 11 | { 12 | private readonly INotificationHandler _inner; 13 | private readonly Polly.IAsyncPolicy _retryPolicy; 14 | 15 | public RetryDecorator(MediatR.INotificationHandler inner) 16 | { 17 | _inner = inner; //TODO: check RetryDecorator doesn't get injected twice 18 | _retryPolicy = Polly.Policy.Handle() 19 | .WaitAndRetryAsync(3, 20 | i => TimeSpan.FromSeconds(i)); 21 | } 22 | 23 | public Task Handle(TNotification notification, CancellationToken cancellationToken) 24 | { 25 | return _retryPolicy.ExecuteAsync(() => _inner.Handle(notification, cancellationToken)); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Persistence.Mongo.Tests/Integration/MongoFixture.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using MongoDB.Driver; 3 | 4 | namespace SuperSafeBank.Service.Core.Persistence.Mongo.Tests.Integration 5 | { 6 | public class MongoFixture : IDisposable 7 | { 8 | private MongoClient _mongoClient; 9 | private readonly string _dbName; 10 | 11 | public IMongoDatabase Database { get; } 12 | 13 | public MongoFixture() 14 | { 15 | var configuration = new ConfigurationBuilder() 16 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 17 | .Build(); 18 | 19 | var connStr = configuration.GetConnectionString("mongo"); 20 | 21 | _dbName = $"tests-{Guid.NewGuid()}"; 22 | 23 | _mongoClient = new MongoClient(connectionString: connStr); 24 | Database = _mongoClient.GetDatabase(_dbName); 25 | } 26 | 27 | public void Dispose() 28 | { 29 | if (null != Database) 30 | { 31 | _mongoClient.DropDatabase(_dbName); 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Domain.Tests/SuperSafeBank.Domain.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | Debug;Release 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | all 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.EventStore.Tests/Integration/EventStoreConnectionWrapperTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.ComponentModel; 4 | using System.Threading.Tasks; 5 | using Xunit; 6 | using FluentAssertions; 7 | 8 | namespace SuperSafeBank.Persistence.EventStore.Tests.Integration 9 | { 10 | 11 | [Trait("Category", "Integration")] 12 | [Category("Integration")] 13 | public class EventStoreConnectionWrapperTests : IClassFixture 14 | { 15 | private readonly EventStoreFixture _fixture; 16 | 17 | public EventStoreConnectionWrapperTests(EventStoreFixture fixture) 18 | { 19 | _fixture = fixture; 20 | } 21 | 22 | [Fact] 23 | public async Task GetConnectionAsync_should_return_connection() 24 | { 25 | var connStr = new Uri(_fixture.ConnectionString); 26 | var logger = NSubstitute.Substitute.For>(); 27 | using var sut = new EventStoreConnectionWrapper(connStr, logger); 28 | 29 | var conn = await sut.GetConnectionAsync(); 30 | conn.Should().NotBeNull(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.SQLServer/SQLCustomerEmailsService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using SuperSafeBank.Domain.Services; 3 | 4 | namespace SuperSafeBank.Service.Core.Persistence.SQLServer 5 | { 6 | public class SQLCustomerEmailsService : ICustomerEmailsService 7 | { 8 | private readonly CustomerDbContext _dbContext; 9 | 10 | public SQLCustomerEmailsService(CustomerDbContext dbContext) 11 | { 12 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 13 | } 14 | 15 | public async Task CreateAsync(string email, Guid customerId, CancellationToken cancellationToken = default) 16 | { 17 | await _dbContext.CustomerEmails.AddAsync(new CustomerEmail(customerId, email), cancellationToken) 18 | .ConfigureAwait(false); 19 | await _dbContext.SaveChangesAsync(cancellationToken) 20 | .ConfigureAwait(false); 21 | } 22 | 23 | public Task ExistsAsync(string email, CancellationToken cancellationToken = default) 24 | => _dbContext.CustomerEmails.AnyAsync(e => e.Email == email, cancellationToken); 25 | } 26 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.EventStore/CustomerEmail.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common.Models; 2 | 3 | namespace SuperSafeBank.Service.Core.Persistence.EventStore 4 | { 5 | public record CustomerEmail : BaseAggregateRoot 6 | { 7 | private CustomerEmail() { } 8 | 9 | public CustomerEmail(string email, Guid customerId) : base(email) 10 | { 11 | if (string.IsNullOrWhiteSpace(email)) 12 | { 13 | throw new ArgumentException($"'{nameof(email)}' cannot be null or whitespace.", nameof(email)); 14 | } 15 | 16 | base.Append(new CustomerEmailEvents.CustomerEmailCreated(this, email, customerId)); 17 | } 18 | 19 | public string Email { get; private set; } 20 | public Guid CustomerId { get; private set; } 21 | 22 | protected override void When(IDomainEvent @event) 23 | { 24 | switch (@event) 25 | { 26 | case CustomerEmailEvents.CustomerEmailCreated c: 27 | this.Id = c.AggregateId; 28 | this.Email = c.Email; 29 | this.CustomerId = c.CustomerId; 30 | break; 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/TestUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Threading.Tasks; 4 | using Xunit; 5 | 6 | namespace SuperSafeBank.Service.Core.Tests 7 | { 8 | public static class TestUtils 9 | { 10 | private static readonly Func DefaultDelayFactory = (c) => TimeSpan.FromSeconds(Math.Pow(2, c)); 11 | 12 | public static async Task Retry(Func> predicate, string because = "", int maxRetries = 3, Func delayFactory = null) 13 | { 14 | int curr = 0; 15 | bool found = false; 16 | while (curr++ < maxRetries) 17 | { 18 | try 19 | { 20 | if (await predicate()) 21 | { 22 | found = true; 23 | break; 24 | } 25 | } 26 | catch (Exception ex) 27 | { 28 | Debug.WriteLine(ex); 29 | } 30 | 31 | var delay = (delayFactory ?? DefaultDelayFactory)(curr); 32 | await Task.Delay(delay); 33 | } 34 | 35 | if (!found) 36 | Assert.False(true, because); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/DomainEvents/CustomerEvents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using SuperSafeBank.Common.Models; 3 | 4 | namespace SuperSafeBank.Domain.DomainEvents 5 | { 6 | public static class CustomerEvents 7 | { 8 | public record CustomerCreated : BaseDomainEvent 9 | { 10 | /// 11 | /// for deserialization 12 | /// 13 | private CustomerCreated() { } 14 | 15 | public CustomerCreated(Customer customer, string firstname, string lastname, Email email) : base(customer) 16 | { 17 | Firstname = firstname; 18 | Lastname = lastname; 19 | Email = email; 20 | } 21 | 22 | public string Firstname { get; init; } 23 | public string Lastname { get; init; } 24 | public Email Email { get; init; } 25 | } 26 | 27 | public record AccountAdded : BaseDomainEvent 28 | { 29 | private AccountAdded() { } 30 | 31 | public AccountAdded(Customer customer, Guid accountId) : base(customer) 32 | { 33 | AccountId = accountId; 34 | } 35 | 36 | public Guid AccountId { get; init; } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/Integration/AggregateTableCreatorTests.cs: -------------------------------------------------------------------------------- 1 | using Dapper; 2 | using Microsoft.Data.SqlClient; 3 | using SuperSafeBank.Domain; 4 | using System.ComponentModel; 5 | 6 | namespace SuperSafeBank.Persistence.SQLServer.Tests.Integration 7 | { 8 | [Category("Integration")] 9 | [Trait("Category", "Integration")] 10 | public class AggregateTableCreatorTests : IClassFixture 11 | { 12 | private readonly DbFixture _fixture; 13 | 14 | public AggregateTableCreatorTests(DbFixture fixture) 15 | { 16 | _fixture = fixture; 17 | } 18 | 19 | [Fact] 20 | public async Task EnsureTableAsync_should_create_table() 21 | { 22 | var provider = await _fixture.CreateDbConnectionStringProviderAsync(); 23 | var sut = new AggregateTableCreator(provider); 24 | await sut.EnsureTableAsync(); 25 | 26 | var tableName = sut.GetTableName(); 27 | using var conn = new SqlConnection(provider.ConnectionString); 28 | await conn.OpenAsync(); 29 | var res = await conn.QueryFirstOrDefaultAsync($"SELECT COUNT(1) FROM {tableName};").ConfigureAwait(false); 30 | res.Should().Be(0); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/SuperSafeBank.Worker.Notifications.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | Debug;Release 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | PreserveNewest 16 | true 17 | PreserveNewest 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Common/Queries/CustomerById.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using SuperSafeBank.Domain; 3 | using System; 4 | using System.Linq; 5 | 6 | namespace SuperSafeBank.Service.Core.Common.Queries 7 | { 8 | public record CustomerAccountDetails(Guid Id, Money Balance) 9 | { 10 | public static CustomerAccountDetails Map(Account account) 11 | => new CustomerAccountDetails(account.Id, account.Balance); 12 | } 13 | 14 | public record CustomerDetails 15 | { 16 | public CustomerDetails(Guid id, string firstname, string lastname, string email, CustomerAccountDetails[] accounts, Money totalBalance) 17 | { 18 | Id = id; 19 | Firstname = firstname; 20 | Lastname = lastname; 21 | Email = email; 22 | Accounts = (accounts ?? Enumerable.Empty()).ToArray(); 23 | TotalBalance = totalBalance; 24 | } 25 | 26 | public Guid Id { get; init; } 27 | public string Firstname { get; init; } 28 | public string Lastname { get; init; } 29 | public string Email { get; init; } 30 | public CustomerAccountDetails[] Accounts { get; init; } 31 | public Money TotalBalance { get; init; } 32 | } 33 | 34 | public record CustomerById(Guid CustomerId) : IRequest; 35 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/NotificationsFactory.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Worker.Notifications.ApiClients; 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace SuperSafeBank.Worker.Notifications 6 | { 7 | public class NotificationsFactory : INotificationsFactory 8 | { 9 | private readonly IAccountsApiClient _accountsApiClient; 10 | 11 | public NotificationsFactory(IAccountsApiClient accountsApiClient) 12 | { 13 | _accountsApiClient = accountsApiClient ?? throw new ArgumentNullException(nameof(accountsApiClient)); 14 | } 15 | 16 | public async Task CreateNewAccountNotificationAsync(Guid accountId) 17 | { 18 | var account = await _accountsApiClient.GetAccountAsync(accountId); 19 | var message = $"dear {account.OwnerFirstName}, a new account was created for you: {accountId}"; 20 | return new Notification(account.OwnerEmail, message); 21 | } 22 | 23 | public async Task CreateTransactionNotificationAsync(Guid accountId) 24 | { 25 | var account = await _accountsApiClient.GetAccountAsync(accountId); 26 | var message = $"dear {account.OwnerFirstName}, a transaction occurred on your account {account.Id}"; 27 | return new Notification(account.OwnerEmail, message); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/QueryHandlers/CustomersArchiveHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 3 | using SuperSafeBank.Service.Core.Common.Queries; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Service.Core.Azure.QueryHandlers 10 | { 11 | public class CustomersArchiveHandler : IRequestHandler> 12 | { 13 | private readonly IViewsContext _dbContext; 14 | 15 | public CustomersArchiveHandler(IViewsContext dbContext) 16 | { 17 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 18 | } 19 | 20 | public async Task> Handle(CustomersArchive request, CancellationToken cancellationToken) 21 | { 22 | var results = new List(); 23 | 24 | var entities = _dbContext.CustomersArchive.QueryAsync(cancellationToken: cancellationToken); 25 | await foreach(var entity in entities) 26 | { 27 | var model = System.Text.Json.JsonSerializer.Deserialize(entity.Data); 28 | results.Add(model); 29 | } 30 | 31 | return results; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/Triggers/Triggers.cs: -------------------------------------------------------------------------------- 1 | using Azure.Messaging.ServiceBus; 2 | using MediatR; 3 | using Microsoft.Azure.Functions.Worker; 4 | using SuperSafeBank.Common.EventBus; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Text.Json; 8 | using System.Threading.Tasks; 9 | 10 | namespace SuperSafeBank.Worker.Core.Azure.Triggers 11 | { 12 | public class Triggers 13 | { 14 | private readonly IMediator _mediator; 15 | 16 | public Triggers(IMediator mediator) 17 | { 18 | _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); 19 | } 20 | 21 | [Function("CustomerTriggers")] 22 | public async Task Run([ServiceBusTrigger("bank-events", "worker", Connection = "EventsBus")] string message, IDictionary userProperties, string messageId) 23 | { 24 | if (!userProperties.TryGetValue("type", out var typeHeader) || typeHeader is null) 25 | throw new ArgumentException($"unable to reconstruct integration event from message {messageId}"); 26 | 27 | var eventTypeName = typeHeader.ToString(); 28 | var eventType = Type.GetType(eventTypeName); 29 | var @event = JsonSerializer.Deserialize(message, eventType) as IIntegrationEvent; 30 | 31 | await _mediator.Publish(@event); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.SQLServer/AggregateEvent.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Common; 2 | using SuperSafeBank.Common.Models; 3 | 4 | namespace SuperSafeBank.Persistence.SQLServer 5 | { 6 | internal record AggregateEvent 7 | { 8 | private AggregateEvent() { } 9 | 10 | public required string AggregateId { get; init; } 11 | 12 | public required long AggregateVersion { get; init; } 13 | 14 | public required string EventType { get; init; } 15 | 16 | public required byte[] Data { get; init; } 17 | 18 | public required DateTimeOffset Timestamp { get; init; } 19 | 20 | public static AggregateEvent Create(IDomainEvent @event, IEventSerializer eventSerializer) 21 | { 22 | if (@event is null) 23 | throw new ArgumentNullException(nameof(@event)); 24 | 25 | if (eventSerializer is null) 26 | throw new ArgumentNullException(nameof(eventSerializer)); 27 | 28 | var data = eventSerializer.Serialize(@event); 29 | var eventType = @event.GetType(); 30 | 31 | return new AggregateEvent() 32 | { 33 | AggregateId = @event.AggregateId.ToString(), 34 | AggregateVersion = @event.AggregateVersion, 35 | EventType = eventType.AssemblyQualifiedName, 36 | Data = data, 37 | Timestamp = @event.When 38 | }; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Persistence.Mongo.Tests/Integration/CustomerEmailsServiceTests.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Service.Core.Persistence.Mongo; 2 | using SuperSafeBank.Service.Core.Persistence.Mongo.Tests.Integration; 3 | using System.ComponentModel; 4 | 5 | namespace SuperSafeBank.Persistence.Mongo.Tests.Integration 6 | { 7 | [Trait("Category", "Integration")] 8 | [Category("Integration")] 9 | public class CustomerEmailsServiceTests : IClassFixture 10 | { 11 | private readonly MongoFixture _fixture; 12 | 13 | public CustomerEmailsServiceTests(MongoFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | [Fact] 19 | public async Task ExistsAsync_should_return_false_if_email_invalid() 20 | { 21 | var sut = new CustomerEmailsService(_fixture.Database); 22 | var result = await sut.ExistsAsync(Guid.NewGuid().ToString()); 23 | result.Should().BeFalse(); 24 | } 25 | 26 | [Fact] 27 | public async Task ExistsAsync_should_return_true_if_email_valid() 28 | { 29 | var sut = new CustomerEmailsService(_fixture.Database); 30 | 31 | var email = Guid.NewGuid().ToString(); 32 | var customerId = Guid.NewGuid(); 33 | await sut.CreateAsync(email, customerId); 34 | 35 | var result = await sut.ExistsAsync(email); 36 | result.Should().BeTrue(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | azure-cli: circleci/azure-cli@1.1.0 5 | 6 | jobs: 7 | build: 8 | docker: 9 | - image: mcr.microsoft.com/dotnet/sdk:7.0 10 | - image: mcr.microsoft.com/azure-storage/azurite 11 | command: azurite --blobHost 0.0.0.0 --blobPort 10000 --queueHost 0.0.0.0 --queuePort 10001 --tableHost 0.0.0.0 --tablePort 10002 12 | - image: eventstore/eventstore:22.10.2-buster-slim 13 | environment: 14 | EVENTSTORE_CLUSTER_SIZE: 1 15 | EVENTSTORE_RUN_PROJECTIONS: All 16 | EVENTSTORE_START_STANDARD_PROJECTIONS: true 17 | EVENTSTORE_EXT_TCP_PORT: 1113 18 | EVENTSTORE_HTTP_PORT: 2113 19 | EVENTSTORE_INSECURE: true 20 | EVENTSTORE_ENABLE_EXTERNAL_TCP: true 21 | EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP: true 22 | - image: 'circleci/mongo:latest' 23 | environment: 24 | MONGO_INITDB_ROOT_USERNAME: root 25 | MONGO_INITDB_ROOT_PASSWORD: password 26 | - image: "mcr.microsoft.com/mssql/server:latest" 27 | environment: 28 | SA_PASSWORD: "Sup3r_Lam3_P4ss" 29 | ACCEPT_EULA: "Y" 30 | 31 | steps: 32 | - checkout 33 | 34 | - run: 35 | name: Build solution 36 | command: | 37 | cd ./src 38 | dotnet restore 39 | dotnet build --no-restore -c Debug 40 | 41 | - run: 42 | name: Test 43 | command: | 44 | cd ./src 45 | dotnet test -c Debug -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/QueryHandlers/AccountByIdHandler.cs: -------------------------------------------------------------------------------- 1 | using Azure; 2 | using MediatR; 3 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 4 | using SuperSafeBank.Service.Core.Common.Queries; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Service.Core.Azure.QueryHandlers 10 | { 11 | public class AccountByIdHandler : IRequestHandler 12 | { 13 | private readonly IViewsContext _dbContext; 14 | 15 | public AccountByIdHandler(IViewsContext dbContext) 16 | { 17 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 18 | } 19 | 20 | public async Task Handle(AccountById request, CancellationToken cancellationToken) 21 | { 22 | Response response = null; 23 | try 24 | { 25 | var key = request.AccountId.ToString(); 26 | 27 | response = await _dbContext.Accounts.GetEntityAsync( 28 | partitionKey: key, 29 | rowKey: key, 30 | cancellationToken: cancellationToken); 31 | } 32 | catch (RequestFailedException ex) 33 | { 34 | return null; 35 | } 36 | 37 | if (response?.Value is null) 38 | return null; 39 | 40 | return System.Text.Json.JsonSerializer.Deserialize(response.Value.Data); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/OnPremiseConfigurationStrategy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Microsoft.Extensions.Configuration; 4 | using MongoDB.Driver; 5 | 6 | namespace SuperSafeBank.Service.Core.Tests.Fixtures 7 | { 8 | public class OnPremiseConfigurationStrategy : IConfigurationStrategy, IDisposable 9 | { 10 | private string _queryDbName; 11 | private string _queryDbConnectionString; 12 | 13 | public void OnConfigureAppConfiguration(IConfigurationBuilder configurationBuilder) 14 | { 15 | configurationBuilder.AddInMemoryCollection(new[] 16 | { 17 | new KeyValuePair("queryDbName", $"bankAccounts_queries_{Guid.NewGuid()}"), 18 | new KeyValuePair("eventsTopicName", $"events_{Guid.NewGuid()}") 19 | }); 20 | 21 | var cfg = configurationBuilder.Build(); 22 | _queryDbName = cfg["queryDbName"]; 23 | _queryDbConnectionString = cfg.GetConnectionString("mongo"); 24 | } 25 | 26 | public void Dispose() 27 | { 28 | if (!string.IsNullOrWhiteSpace(_queryDbConnectionString)) 29 | { 30 | var client = new MongoClient(_queryDbConnectionString); 31 | client.DropDatabase(_queryDbName); 32 | } 33 | } 34 | 35 | public IQueryModelsSeeder CreateSeeder() => new OnPremiseQueryModelsSeeder(_queryDbConnectionString, _queryDbName); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.EvenireDB.Tests/SuperSafeBank.Persistence.EvenireDB.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/QueryHandlers/CustomerByIdHandler.cs: -------------------------------------------------------------------------------- 1 | using Azure; 2 | using MediatR; 3 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 4 | using SuperSafeBank.Service.Core.Common.Queries; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Service.Core.Azure.QueryHandlers 10 | { 11 | public class CustomerByIdHandler : IRequestHandler 12 | { 13 | private readonly IViewsContext _dbContext; 14 | 15 | public CustomerByIdHandler(IViewsContext dbContext) 16 | { 17 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 18 | } 19 | 20 | public async Task Handle(CustomerById request, CancellationToken cancellationToken) 21 | { 22 | Response response = null; 23 | try 24 | { 25 | var key = request.CustomerId.ToString(); 26 | 27 | response = await _dbContext.CustomersDetails.GetEntityAsync( 28 | partitionKey: key, 29 | rowKey: key, 30 | cancellationToken: cancellationToken); 31 | } 32 | catch (RequestFailedException ex) 33 | { 34 | return null; 35 | } 36 | 37 | if (response?.Value is null) 38 | return null; 39 | 40 | return System.Text.Json.JsonSerializer.Deserialize(response.Value.Data); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/SuperSafeBank.Web.API/bin/Debug/netcoreapp3.1/SuperSafeBank.Web.API.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/SuperSafeBank.Web.API", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "^\\s*Now listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach", 33 | "processId": "${command:pickProcess}" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.Hosting; 4 | using Serilog; 5 | using Serilog.Sinks.Grafana.Loki; 6 | 7 | namespace SuperSafeBank.Service.Core 8 | { 9 | public class Program 10 | { 11 | public static void Main(string[] args) 12 | { 13 | CreateHostBuilder(args).Build().Run(); 14 | } 15 | 16 | public static IHostBuilder CreateHostBuilder(string[] args) => 17 | Host.CreateDefaultBuilder(args) 18 | .ConfigureAppConfiguration((ctx, builder)=> 19 | { 20 | builder.AddJsonFile("appsettings.json", optional: false) 21 | .AddEnvironmentVariables() 22 | .AddUserSecrets(optional: true); 23 | }) 24 | .ConfigureWebHostDefaults(webBuilder => 25 | { 26 | webBuilder.UseStartup(); 27 | }).UseSerilog((ctx, cfg) => 28 | { 29 | var lokiConnStr = ctx.Configuration.GetConnectionString("loki"); 30 | 31 | cfg.Enrich.FromLogContext() 32 | .Enrich.WithProperty("Application", ctx.HostingEnvironment.ApplicationName) 33 | .Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName) 34 | .WriteTo.Console() 35 | .WriteTo.GrafanaLoki(lokiConnStr); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/SuperSafeBank.Worker.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | true 18 | PreserveNewest 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Azure.Tests/SuperSafeBank.Service.Core.Azure.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | true 18 | PreserveNewest 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | all 29 | 30 | 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | all 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure.Common/Persistence/ViewTableEntity.cs: -------------------------------------------------------------------------------- 1 | using Azure; 2 | using Azure.Data.Tables; 3 | using SuperSafeBank.Service.Core.Common.Queries; 4 | using System.Text.Json; 5 | 6 | namespace SuperSafeBank.Service.Core.Azure.Common.Persistence 7 | { 8 | public record ViewTableEntity : ITableEntity 9 | { 10 | public string PartitionKey { get; set; } 11 | public string RowKey { get; set; } 12 | public string Data { get; set; } 13 | public DateTimeOffset? Timestamp { get; set; } 14 | public ETag ETag { get; set; } 15 | 16 | public static ViewTableEntity Map(CustomerDetails customerView) 17 | => new ViewTableEntity() 18 | { 19 | PartitionKey = customerView.Id.ToString(), 20 | RowKey = customerView.Id.ToString(), 21 | Data = JsonSerializer.Serialize(customerView), 22 | }; 23 | 24 | public static ViewTableEntity Map(CustomerArchiveItem customerView) 25 | => new ViewTableEntity() 26 | { 27 | PartitionKey = customerView.Id.ToString(), 28 | RowKey = customerView.Id.ToString(), 29 | Data = System.Text.Json.JsonSerializer.Serialize(customerView), 30 | }; 31 | 32 | public static ViewTableEntity Map(AccountDetails accountView) 33 | => new ViewTableEntity() 34 | { 35 | PartitionKey = accountView.Id.ToString(), 36 | RowKey = accountView.Id.ToString(), 37 | Data = JsonSerializer.Serialize(accountView) 38 | }; 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/SuperSafeBank.Service.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release; 6 | 70be17c8-2171-4e52-b990-52de4dd6ec94 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/EventsConsumerWorker.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using SuperSafeBank.Common.EventBus; 5 | 6 | namespace SuperSafeBank.Worker.Core 7 | { 8 | public class EventsConsumerWorker : BackgroundService 9 | { 10 | private readonly IServiceScopeFactory _scopeFactory; 11 | private IEventConsumer _consumer; 12 | 13 | public EventsConsumerWorker(IServiceScopeFactory scopeFactory) 14 | { 15 | _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); 16 | } 17 | 18 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 19 | { 20 | using var scope = _scopeFactory.CreateScope(); 21 | _consumer = scope.ServiceProvider.GetRequiredService(); 22 | _consumer.EventReceived += this.OnEventReceived; 23 | await _consumer.StartConsumeAsync(stoppingToken); 24 | } 25 | 26 | public override Task StopAsync(CancellationToken cancellationToken) 27 | { 28 | if(_consumer is not null) 29 | _consumer.EventReceived -= this.OnEventReceived; 30 | 31 | return base.StopAsync(cancellationToken); 32 | } 33 | 34 | private async Task OnEventReceived(object s, IIntegrationEvent @event) 35 | { 36 | using var innerScope = _scopeFactory.CreateScope(); 37 | var mediator = innerScope.ServiceProvider.GetRequiredService(); 38 | await mediator.Publish(@event, CancellationToken.None); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/SuperSafeBank.Worker.Core.Azure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | v4 5 | Exe 6 | 99685c81-4f68-4327-bb12-98f3adbe825c 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | PreserveNewest 24 | 25 | 26 | PreserveNewest 27 | Never 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/SuperSafeBank.Service.Core.Azure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | v4 5 | Exe 6 | 6184d0f2-405c-4fd3-a499-bcd96a4bdb7d 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | PreserveNewest 24 | 25 | 26 | PreserveNewest 27 | Never 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/Program.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using SuperSafeBank.Domain.Services; 6 | using SuperSafeBank.Persistence.Azure; 7 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 8 | using SuperSafeBank.Service.Core.Common; 9 | using SuperSafeBank.Worker.Core.Azure.EventHandlers; 10 | using System.Reflection; 11 | 12 | var builder = new HostBuilder(); 13 | await builder.ConfigureFunctionsWorkerDefaults() 14 | .ConfigureHostConfiguration(builder => 15 | { 16 | builder.AddUserSecrets(Assembly.GetExecutingAssembly(), true); 17 | }) 18 | .ConfigureServices((ctx, services) => 19 | { 20 | var eventsRepositoryConfig = new EventsRepositoryConfig(ctx.Configuration["EventsStorage"], ctx.Configuration["EventTablesPrefix"]); 21 | 22 | services.AddScoped() 23 | .Scan(scan => 24 | { 25 | scan.FromAssembliesOf(typeof(CustomerDetailsHandler)) 26 | .RegisterHandlers(typeof(INotificationHandler<>)); 27 | }) 28 | .AddTransient() 29 | .AddSingleton(provider => 30 | { 31 | var connStr = ctx.Configuration["QueryModelsStorage"]; 32 | var tablesPrefix = ctx.Configuration["QueryModelsTablePrefix"]; 33 | return new ViewsContext(connStr, tablesPrefix); 34 | }).AddAzurePersistence(eventsRepositoryConfig); 35 | }) 36 | .Build() 37 | .RunAsync(); -------------------------------------------------------------------------------- /test/SuperSafeBank.Worker.Core.Azure.Tests/SuperSafeBank.Worker.Core.Azure.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | true 18 | PreserveNewest 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | runtime; build; native; contentfiles; analyzers; buildtransitive 29 | all 30 | 31 | 32 | runtime; build; native; contentfiles; analyzers; buildtransitive 33 | all 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.EventStore.Tests/SuperSafeBank.Persistence.EventStore.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | PreserveNewest 16 | true 17 | PreserveNewest 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | all 29 | 30 | 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | all 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.Azure/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SuperSafeBank.Common; 4 | using SuperSafeBank.Common.Models; 5 | using SuperSafeBank.Domain; 6 | using SuperSafeBank.Domain.DomainEvents; 7 | using System; 8 | 9 | namespace SuperSafeBank.Persistence.Azure 10 | { 11 | public record EventsRepositoryConfig(string ConnectionString, string TablePrefix = ""); 12 | 13 | public static class IServiceCollectionExtensions 14 | { 15 | public static IServiceCollection AddAzurePersistence(this IServiceCollection services, EventsRepositoryConfig config) 16 | { 17 | return services 18 | .AddSingleton(new JsonEventSerializer(new[] 19 | { 20 | typeof(CustomerEvents.CustomerCreated).Assembly 21 | })) 22 | .AddEventsRepository(config) 23 | .AddEventsRepository(config); 24 | } 25 | 26 | private static IServiceCollection AddEventsRepository(this IServiceCollection services, EventsRepositoryConfig config) 27 | where TA : class, IAggregateRoot 28 | { 29 | var aggregateType = typeof(TA); 30 | var tableName = $"{config.TablePrefix}{aggregateType.Name}Events"; 31 | 32 | return services.AddSingleton>(ctx => 33 | { 34 | var client = new TableClient(config.ConnectionString, tableName); 35 | client.CreateIfNotExists(); 36 | 37 | var eventDeserializer = ctx.GetRequiredService(); 38 | return new AggregateRepository(client, eventDeserializer); 39 | }); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/DomainEvents/AccountEvents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using SuperSafeBank.Common.Models; 3 | 4 | namespace SuperSafeBank.Domain.DomainEvents 5 | { 6 | public static class AccountEvents 7 | { 8 | public record AccountCreated : BaseDomainEvent 9 | { 10 | /// 11 | /// for deserialization 12 | /// 13 | private AccountCreated() { } 14 | 15 | public AccountCreated(Account account, Customer owner, Currency currency) : base(account) 16 | { 17 | if (owner is null) 18 | throw new ArgumentNullException(nameof(owner)); 19 | 20 | OwnerId = owner.Id; 21 | Currency = currency ?? throw new ArgumentNullException(nameof(currency)); 22 | } 23 | 24 | public Guid OwnerId { get; init; } 25 | public Currency Currency { get; init; } 26 | } 27 | 28 | public record Deposit : BaseDomainEvent 29 | { 30 | /// 31 | /// for deserialization 32 | /// 33 | private Deposit() { } 34 | 35 | public Deposit(Account account, Money amount) : base(account) 36 | { 37 | Amount = amount; 38 | } 39 | 40 | public Money Amount { get; init; } 41 | } 42 | 43 | public record Withdrawal : BaseDomainEvent 44 | { 45 | /// 46 | /// for deserialization 47 | /// 48 | private Withdrawal() { } 49 | 50 | public Withdrawal(Account account, Money amount) : base(account) 51 | { 52 | Amount = amount; 53 | } 54 | 55 | public Money Amount { get; init; } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Persistence.Mongo.Tests/SuperSafeBank.Service.Core.Persistence.Mongo.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PreserveNewest 19 | true 20 | PreserveNewest 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | all 33 | 34 | 35 | runtime; build; native; contentfiles; analyzers; buildtransitive 36 | all 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Currency.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace SuperSafeBank.Domain 5 | { 6 | public record Currency 7 | { 8 | public Currency(string name, string symbol) 9 | { 10 | if(string.IsNullOrWhiteSpace(symbol)) 11 | throw new ArgumentNullException(nameof(symbol)); 12 | if (string.IsNullOrWhiteSpace(name)) 13 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); 14 | 15 | Symbol = symbol; 16 | Name = name; 17 | } 18 | 19 | public string Name { get; } 20 | public string Symbol { get; } 21 | 22 | public override string ToString() 23 | { 24 | return this.Symbol; 25 | } 26 | 27 | #region Factory 28 | 29 | private static readonly IDictionary Currencies; 30 | 31 | static Currency() 32 | { 33 | Currencies = new Dictionary() 34 | { 35 | { Euro.Name, Euro }, 36 | { CanadianDollar.Name, CanadianDollar }, 37 | { USDollar.Name, USDollar }, 38 | }; 39 | } 40 | 41 | public static Currency FromCode(string code) 42 | { 43 | if(string.IsNullOrWhiteSpace(code)) 44 | throw new ArgumentNullException(nameof(code)); 45 | var normalizedCode = code.Trim().ToUpper(); 46 | if(!Currencies.ContainsKey(normalizedCode)) 47 | throw new ArgumentException($"Invalid code: '{code}'", nameof(code)); 48 | return Currencies[normalizedCode]; 49 | } 50 | 51 | public static Currency Euro => new Currency("EUR", "€"); 52 | public static Currency CanadianDollar => new Currency("CAD", "CA$"); 53 | public static Currency USDollar => new Currency("USD", "US$"); 54 | 55 | #endregion Factory 56 | } 57 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Commands/Deposit.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using SuperSafeBank.Common; 6 | using SuperSafeBank.Common.EventBus; 7 | using SuperSafeBank.Domain.IntegrationEvents; 8 | using SuperSafeBank.Domain.Services; 9 | 10 | namespace SuperSafeBank.Domain.Commands 11 | { 12 | public record Deposit : IRequest 13 | { 14 | public Deposit(Guid accountId, Money amount) 15 | { 16 | AccountId = accountId; 17 | Amount = amount ?? throw new ArgumentNullException(nameof(amount)); 18 | } 19 | 20 | public Guid AccountId { get; } 21 | public Money Amount { get; } 22 | } 23 | 24 | public class DepositHandler : IRequestHandler 25 | { 26 | private readonly IAggregateRepository _accountEventsService; 27 | private readonly ICurrencyConverter _currencyConverter; 28 | private readonly IEventProducer _eventProducer; 29 | 30 | public DepositHandler(IAggregateRepository accountEventsService, ICurrencyConverter currencyConverter, IEventProducer eventProducer) 31 | { 32 | _accountEventsService = accountEventsService; 33 | _currencyConverter = currencyConverter; 34 | _eventProducer = eventProducer; 35 | } 36 | 37 | public async Task Handle(Deposit command, CancellationToken cancellationToken) 38 | { 39 | var account = await _accountEventsService.RehydrateAsync(command.AccountId); 40 | if(null == account) 41 | throw new ArgumentOutOfRangeException(nameof(Deposit.AccountId), "invalid account id"); 42 | 43 | account.Deposit(command.Amount, _currencyConverter); 44 | 45 | await _accountEventsService.PersistAsync(account); 46 | 47 | var @event = new TransactionHappened(Guid.NewGuid(), account.Id); 48 | await _eventProducer.DispatchAsync(@event, cancellationToken); 49 | } 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Commands/Withdraw.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using SuperSafeBank.Common; 6 | using SuperSafeBank.Common.EventBus; 7 | using SuperSafeBank.Domain.IntegrationEvents; 8 | using SuperSafeBank.Domain.Services; 9 | 10 | namespace SuperSafeBank.Domain.Commands 11 | { 12 | public record Withdraw : IRequest 13 | { 14 | public Withdraw(Guid accountId, Money amount) 15 | { 16 | AccountId = accountId; 17 | Amount = amount ?? throw new ArgumentNullException(nameof(amount)); 18 | } 19 | 20 | public Guid AccountId { get; } 21 | public Money Amount { get; } 22 | } 23 | 24 | 25 | public class WithdrawHandler : IRequestHandler 26 | { 27 | private readonly IAggregateRepository _accountEventsService; 28 | private readonly ICurrencyConverter _currencyConverter; 29 | private readonly IEventProducer _eventProducer; 30 | 31 | public WithdrawHandler(IAggregateRepository accountEventsService, ICurrencyConverter currencyConverter, IEventProducer eventProducer) 32 | { 33 | _accountEventsService = accountEventsService; 34 | _currencyConverter = currencyConverter; 35 | _eventProducer = eventProducer; 36 | } 37 | 38 | public async Task Handle(Withdraw command, CancellationToken cancellationToken) 39 | { 40 | var account = await _accountEventsService.RehydrateAsync(command.AccountId); 41 | if (null == account) 42 | throw new ArgumentOutOfRangeException(nameof(Withdraw.AccountId), "invalid account id"); 43 | 44 | account.Withdraw(command.Amount, _currencyConverter); 45 | 46 | await _accountEventsService.PersistAsync(account); 47 | 48 | var @event = new TransactionHappened(Guid.NewGuid(), account.Id); 49 | await _eventProducer.DispatchAsync(@event, cancellationToken); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.Azure/EventData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Azure; 3 | using Azure.Data.Tables; 4 | using SuperSafeBank.Common; 5 | using SuperSafeBank.Common.Models; 6 | 7 | namespace SuperSafeBank.Persistence.Azure 8 | { 9 | public record EventData : ITableEntity 10 | { 11 | /// 12 | /// this is the Aggregate id 13 | /// 14 | public string PartitionKey { get; set; } 15 | 16 | /// 17 | /// aggregate version on the event 18 | /// 19 | public string RowKey { get; set; } 20 | 21 | /// 22 | /// the event type 23 | /// 24 | public string EventType { get; init; } 25 | 26 | /// 27 | /// serialized event data 28 | /// 29 | public byte[] Data { get; init; } 30 | 31 | /// 32 | /// aggregate version on the event 33 | /// 34 | public long AggregateVersion { get; init; } 35 | 36 | public DateTimeOffset? Timestamp { get; set; } 37 | public ETag ETag { get; set; } 38 | 39 | public static EventData Create(IDomainEvent @event, IEventSerializer eventSerializer) 40 | { 41 | if (@event is null) 42 | throw new ArgumentNullException(nameof(@event)); 43 | 44 | if (eventSerializer is null) 45 | throw new ArgumentNullException(nameof(eventSerializer)); 46 | 47 | var data = eventSerializer.Serialize(@event); 48 | var eventType = @event.GetType(); 49 | 50 | return new EventData() 51 | { 52 | PartitionKey = @event.AggregateId.ToString(), 53 | RowKey = @event.AggregateVersion.ToString(), 54 | AggregateVersion = @event.AggregateVersion, 55 | EventType = eventType.AssemblyQualifiedName, 56 | Data = data 57 | }; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Azure.Tests/Integration/Fixtures/StorageTableFixutre.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using Microsoft.Extensions.Configuration; 3 | using SuperSafeBank.Common.Models; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Persistence.Azure.Tests.Integration.Fixtures 10 | { 11 | public class StorageTableFixutre : Xunit.IAsyncLifetime 12 | { 13 | private readonly Queue _tableClients = new(); 14 | 15 | private readonly string _tablePrefix; 16 | private readonly string _connStr; 17 | 18 | public StorageTableFixutre() 19 | { 20 | var configuration = new ConfigurationBuilder() 21 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) 22 | .AddUserSecrets(optional: true) 23 | .AddEnvironmentVariables() 24 | .Build(); 25 | 26 | _connStr = configuration.GetConnectionString("storageTable"); 27 | if(string.IsNullOrWhiteSpace(_connStr)) 28 | throw new ArgumentException("invalid storage account connection string"); 29 | 30 | _tablePrefix = configuration["tablePrefix"]; 31 | } 32 | 33 | public async Task CreateTableClientAsync() 34 | where TA : IAggregateRoot 35 | { 36 | var client = new TableClient(_connStr, $"{_tablePrefix}{nameof(TA)}{DateTime.UtcNow.Ticks}"); 37 | await client.CreateIfNotExistsAsync(); 38 | 39 | _tableClients.Enqueue(client); 40 | 41 | return client; 42 | } 43 | 44 | public async Task DisposeAsync() 45 | { 46 | while (_tableClients.Any()) 47 | { 48 | await _tableClients.Dequeue().DeleteAsync(); 49 | } 50 | } 51 | 52 | public Task InitializeAsync() => Task.CompletedTask; 53 | } 54 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/OnPremiseQueryModelsSeeder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using MongoDB.Driver; 4 | using SuperSafeBank.Service.Core.Persistence.Mongo; 5 | 6 | namespace SuperSafeBank.Service.Core.Tests.Fixtures 7 | { 8 | public class OnPremiseQueryModelsSeeder : IQueryModelsSeeder 9 | { 10 | private readonly QueryDbContext _dbContext; 11 | 12 | public OnPremiseQueryModelsSeeder(string queryDbConnectionString, string queryDbName) 13 | { 14 | if (string.IsNullOrWhiteSpace(queryDbConnectionString)) 15 | throw new ArgumentException($"'{nameof(queryDbConnectionString)}' cannot be null or whitespace", nameof(queryDbConnectionString)); 16 | if (string.IsNullOrWhiteSpace(queryDbName)) 17 | throw new ArgumentException($"'{nameof(queryDbName)}' cannot be null or whitespace", nameof(queryDbName)); 18 | 19 | var client = new MongoClient(queryDbConnectionString); 20 | var db = client.GetDatabase(queryDbName); 21 | 22 | _dbContext = new QueryDbContext(db); 23 | } 24 | 25 | public async Task CreateCustomerDetails(Common.Queries.CustomerDetails model) 26 | { 27 | if (model is null) 28 | throw new ArgumentNullException(nameof(model)); 29 | 30 | await _dbContext.CustomersDetails.InsertOneAsync(model); 31 | } 32 | 33 | public async Task CreateCustomerArchiveItem(Common.Queries.CustomerArchiveItem model) 34 | { 35 | if (model is null) 36 | throw new ArgumentNullException(nameof(model)); 37 | 38 | await _dbContext.Customers.InsertOneAsync(model); 39 | } 40 | 41 | public async Task CreateAccountDetails(Common.Queries.AccountDetails model) 42 | { 43 | if (model is null) 44 | throw new ArgumentNullException(nameof(model)); 45 | 46 | await _dbContext.AccountsDetails.InsertOneAsync(model); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core/Controllers/CustomersController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Mvc; 6 | using SuperSafeBank.Domain; 7 | using SuperSafeBank.Domain.Commands; 8 | using SuperSafeBank.Service.Core.Common.Queries; 9 | using SuperSafeBank.Service.Core.DTOs; 10 | 11 | namespace SuperSafeBank.Service.Core.Controllers 12 | { 13 | [ApiController] 14 | [Route("[controller]")] 15 | public class CustomersController : ControllerBase 16 | { 17 | private readonly IMediator _mediator; 18 | 19 | public CustomersController(IMediator mediator) 20 | { 21 | _mediator = mediator; 22 | } 23 | 24 | [HttpPost] 25 | public async Task Create(CreateCustomerDto dto, CancellationToken cancellationToken = default) 26 | { 27 | if (null == dto) 28 | return BadRequest(); 29 | var command = new CreateCustomer(Guid.NewGuid(), dto.FirstName, dto.LastName, dto.Email); 30 | await _mediator.Send(command, cancellationToken); 31 | 32 | return CreatedAtAction("GetCustomer", new { id = command.CustomerId }, command); 33 | } 34 | 35 | [HttpGet, Route("{id:guid}", Name = "GetCustomer")] 36 | public async Task GetCustomer(Guid id, CancellationToken cancellationToken= default) 37 | { 38 | var query = new CustomerById(id); 39 | var result = await _mediator.Send(query, cancellationToken); 40 | if (null == result) 41 | return NotFound(); 42 | return Ok(result); 43 | } 44 | 45 | [HttpGet] 46 | public async Task Get(CancellationToken cancellationToken = default) 47 | { 48 | var query = new CustomersArchive(); 49 | var results = await _mediator.Send(query, cancellationToken); 50 | if (null == results) 51 | return NotFound(); 52 | return Ok(results); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Money.cs: -------------------------------------------------------------------------------- 1 | using SuperSafeBank.Domain.Services; 2 | using System; 3 | 4 | namespace SuperSafeBank.Domain 5 | { 6 | public record Money 7 | { 8 | public Money(Currency currency, decimal value) 9 | { 10 | Value = value; 11 | Currency = currency ?? throw new ArgumentNullException(nameof(currency)); 12 | } 13 | 14 | public decimal Value { get; } 15 | public Currency Currency { get; } 16 | 17 | public Money Subtract(Money other, ICurrencyConverter converter = null) 18 | { 19 | if (other is null) 20 | throw new ArgumentNullException(nameof(other)); 21 | 22 | if (other.Currency != this.Currency) 23 | { 24 | if (converter is null) 25 | throw new ArgumentNullException(nameof(converter), "Currency Converter is requried when currencies don't match"); 26 | 27 | var converted = converter.Convert(other, this.Currency); 28 | return new Money(this.Currency, this.Value - converted.Value); 29 | } 30 | 31 | return new Money(this.Currency, this.Value - other.Value); 32 | } 33 | 34 | public Money Add(Money other, ICurrencyConverter converter = null) 35 | { 36 | if (other is null) 37 | throw new ArgumentNullException(nameof(other)); 38 | 39 | if (other.Currency != this.Currency) 40 | { 41 | if (converter is null) 42 | throw new ArgumentNullException(nameof(converter), "Currency Converter is requried when currencies don't match"); 43 | 44 | var converted = converter.Convert(other, this.Currency); 45 | return new Money(this.Currency, this.Value + converted.Value); 46 | } 47 | 48 | return new Money(this.Currency, this.Value + other.Value); 49 | } 50 | 51 | public override string ToString() => $"{Value} {Currency}"; 52 | 53 | public static Money Zero(Currency currency) => new Money(currency, 0); 54 | } 55 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/SuperSafeBank.Persistence.SQLServer.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PreserveNewest 19 | true 20 | PreserveNewest 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | all 32 | 33 | 34 | runtime; build; native; contentfiles; analyzers; buildtransitive 35 | all 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # SuperSafeBank 2 | 3 | [![SuperSafeBank](https://circleci.com/gh/mizrael/SuperSafeBank.svg?style=shield)](https://app.circleci.com/pipelines/github/mizrael/SuperSafeBank) 4 | 5 | This repository shows how to implement Event Sourcing, CQRS and DDD in .NET Core, using a Bank as example. 6 | 7 | The code has been used as example accompaining a few series of articles on [my personal blog](https://www.davidguida.net): 8 | - https://www.davidguida.net/event-sourcing-in-net-core-part-1-a-gentle-introduction/ 9 | - https://www.davidguida.net/event-sourcing-on-azure-part-1-architecture-plan/ 10 | - https://www.davidguida.net/event-sourcing-on-azure-part-2-events-persistence/ 11 | - https://www.davidguida.net/event-sourcing-on-azure-part-3-command-validation/ 12 | - https://www.davidguida.net/event-sourcing-on-azure-part-4-integration-events/ 13 | - https://www.davidguida.net/my-event-sourcing-journey-so-far/ 14 | - https://www.davidguida.net/event-sourcing-things-to-consider/ 15 | 16 | An ASP.NET Core API is used as entry-point for all the client-facing operations: 17 | - create customers 18 | - create accounts 19 | - deposit money 20 | - withdraw money 21 | 22 | ## Infrastructure 23 | The Cloud can be hosted on Azure, using Azure Functions, [Storage Table](https://azure.microsoft.com/en-ca/services/storage/tables/?WT.mc_id=DOP-MVP-5003878) to persist Events and Materialized Views, and [ServiceBus](https://docs.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview?WT.mc_id=DOP-MVP-5003878) to broadcast the Events. 24 | 25 | An "on-premise" version is available as well, which uses 26 | - Kafka to broadcast the integration events 27 | - MongoDb to store the QueryModels used by the API 28 | or SQLServer can be used as persistence layer for aggregates. 29 | - several options are available for the Persistence layer to store aggregates: 30 | - SQLServer 31 | - [EventStore](https://eventstore.com/) 32 | - [EvenireDB](https://github.com/mizrael/EvenireDB) 33 | 34 | The on-premise infrastructure can be spin up by simply running `docker-compose up` from the root folder. 35 | 36 | ## Give a Star! ⭐️ 37 | Did you like this project? Give it a star, fork it, send me a PR or sponsor me! 38 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/Fixtures/WebApiFixture.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.AspNetCore.TestHost; 3 | using Microsoft.Extensions.Configuration; 4 | using System; 5 | using System.Net.Http; 6 | 7 | namespace SuperSafeBank.Service.Core.Tests.Fixtures; 8 | 9 | public class WebApiFixture : IDisposable 10 | where TStartup : class 11 | { 12 | private readonly IConfigurationStrategy _configurationStrategy; 13 | 14 | public WebApiFixture() 15 | { 16 | _configurationStrategy = new OnPremiseConfigurationStrategy(); 17 | 18 | var builder = new WebHostBuilder() 19 | .UseEnvironment("Development") 20 | .ConfigureAppConfiguration((ctx, configurationBuilder) => 21 | { 22 | configurationBuilder.AddJsonFile("appsettings.json", false); 23 | 24 | var aspEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); 25 | if (!string.IsNullOrWhiteSpace(aspEnv)) 26 | configurationBuilder.AddJsonFile($"appsettings.{aspEnv}.json", true); 27 | 28 | configurationBuilder 29 | .AddUserSecrets>(optional: true) 30 | .AddEnvironmentVariables(); 31 | 32 | if (null == _configurationStrategy) 33 | throw new Exception("configuration strategy not set"); 34 | 35 | _configurationStrategy.OnConfigureAppConfiguration(configurationBuilder); 36 | this.QueryModelsSeeder = _configurationStrategy.CreateSeeder(); 37 | }) 38 | .UseStartup(); 39 | 40 | var server = new TestServer(builder); 41 | this.HttpClient = server.CreateClient(); 42 | } 43 | 44 | public void Dispose() 45 | { 46 | if (null != this.HttpClient) 47 | { 48 | this.HttpClient.Dispose(); 49 | this.HttpClient = null; 50 | } 51 | 52 | if(_configurationStrategy is IDisposable ds) 53 | ds.Dispose(); 54 | } 55 | 56 | public HttpClient HttpClient { get; private set; } 57 | public IQueryModelsSeeder QueryModelsSeeder { get; private set; } 58 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Tests/SuperSafeBank.Service.Core.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | Debug;Release 9 | 10 | 67a63f27-36cf-46e2-b905-04388b3560a5 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PreserveNewest 20 | true 21 | PreserveNewest 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | all 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | 33 | 34 | all 35 | runtime; build; native; contentfiles; analyzers; buildtransitive 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Azure/EventProducer.cs: -------------------------------------------------------------------------------- 1 | using Azure.Messaging.ServiceBus; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Common.EventBus; 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace SuperSafeBank.Transport.Azure 9 | { 10 | public class EventProducer : IEventProducer, IAsyncDisposable 11 | { 12 | private readonly ILogger _logger; 13 | 14 | private ServiceBusSender _sender; 15 | 16 | public EventProducer(ServiceBusClient senderFactory, 17 | string topicName, 18 | ILogger logger) 19 | { 20 | if (senderFactory == null) 21 | throw new ArgumentNullException(nameof(senderFactory)); 22 | if (string.IsNullOrWhiteSpace(topicName)) 23 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(topicName)); 24 | 25 | _logger = logger; 26 | _sender = senderFactory.CreateSender(topicName); 27 | } 28 | 29 | public async Task DispatchAsync(IIntegrationEvent @event, CancellationToken cancellationToken = default) 30 | { 31 | if (null == @event) 32 | throw new ArgumentNullException(nameof(@event)); 33 | 34 | _logger.LogInformation("publishing event {EventId} ...", @event.Id); 35 | 36 | var eventType = @event.GetType(); 37 | 38 | var serialized = System.Text.Json.JsonSerializer.Serialize(@event, eventType); 39 | 40 | var message = new ServiceBusMessage(serialized) 41 | { 42 | MessageId = @event.Id.ToString(), 43 | ApplicationProperties = 44 | { 45 | {"type", eventType.AssemblyQualifiedName} 46 | } 47 | }; 48 | 49 | await _sender.SendMessageAsync(message); 50 | } 51 | 52 | public async ValueTask DisposeAsync() 53 | { 54 | if(_sender is not null) 55 | await _sender.DisposeAsync(); 56 | _sender = null; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Transport.Kafka/EventProducer.cs: -------------------------------------------------------------------------------- 1 | using Confluent.Kafka; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Common.EventBus; 4 | using System.Text; 5 | 6 | namespace SuperSafeBank.Transport.Kafka 7 | { 8 | public class EventProducer : IDisposable, IEventProducer 9 | { 10 | private IProducer _producer; 11 | private readonly KafkaProducerConfig _config; 12 | private readonly ILogger _logger; 13 | 14 | public EventProducer(KafkaProducerConfig config, ILogger logger) 15 | { 16 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 17 | _config = config ?? throw new ArgumentNullException(nameof(config)); 18 | 19 | var producerConfig = new ProducerConfig { BootstrapServers = config.KafkaConnectionString }; 20 | var producerBuilder = new ProducerBuilder(producerConfig); 21 | producerBuilder.SetKeySerializer(new KeySerializer()); 22 | _producer = producerBuilder.Build(); 23 | } 24 | 25 | public async Task DispatchAsync(IIntegrationEvent @event, CancellationToken cancellationToken = default) 26 | { 27 | if (null == @event) 28 | throw new ArgumentNullException(nameof(@event)); 29 | 30 | _logger.LogInformation("publishing event {EventId} ...", @event.Id); 31 | var eventType = @event.GetType(); 32 | 33 | var serialized = System.Text.Json.JsonSerializer.Serialize(@event, eventType); 34 | 35 | var headers = new Headers 36 | { 37 | {"id", Encoding.UTF8.GetBytes(@event.Id.ToString())}, 38 | {"type", Encoding.UTF8.GetBytes(eventType.AssemblyQualifiedName)} 39 | }; 40 | 41 | var message = new Message() 42 | { 43 | Key = @event.Id, 44 | Value = serialized, 45 | Headers = headers 46 | }; 47 | 48 | await _producer.ProduceAsync(_config.TopicName, message); 49 | } 50 | 51 | public void Dispose() 52 | { 53 | _producer?.Dispose(); 54 | _producer = null; 55 | } 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Services/CustomerEmailsService.cs: -------------------------------------------------------------------------------- 1 | using Azure; 2 | using Azure.Data.Tables; 3 | using SuperSafeBank.Domain.Services; 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace SuperSafeBank.Service.Core.Azure.Services 9 | { 10 | public class CustomerEmailsService : ICustomerEmailsService 11 | { 12 | private readonly TableClient _client; 13 | 14 | public CustomerEmailsService(TableClient client) 15 | { 16 | _client = client; 17 | } 18 | 19 | public async Task ExistsAsync(string email, CancellationToken cancellationToken = default) 20 | { 21 | if (string.IsNullOrWhiteSpace(email)) 22 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(email)); 23 | 24 | var results = _client.QueryAsync(ce => ce.PartitionKey == email, cancellationToken: cancellationToken).ConfigureAwait(false); 25 | 26 | await foreach(var result in results) 27 | { 28 | return true; 29 | } 30 | 31 | return false; 32 | } 33 | 34 | public async Task CreateAsync(string email, Guid customerId, CancellationToken cancellationToken = default) 35 | { 36 | if (string.IsNullOrWhiteSpace(email)) 37 | throw new ArgumentException("Value cannot be null or whitespace.", nameof(email)); 38 | 39 | var item = CustomerEmail.Create(email, customerId); 40 | await _client.AddEntityAsync(item, cancellationToken); 41 | } 42 | } 43 | 44 | internal record CustomerEmail : ITableEntity 45 | { 46 | public string Email => this.PartitionKey; 47 | public string CustomerId => this.RowKey; 48 | 49 | public string PartitionKey { get; set; } 50 | public string RowKey { get; set; } 51 | public DateTimeOffset? Timestamp { get; set; } 52 | public ETag ETag { get; set; } 53 | 54 | public static CustomerEmail Create(string email, Guid customerId) 55 | => new CustomerEmail() 56 | { 57 | PartitionKey = email, 58 | RowKey = customerId.ToString() 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Commands/CreateAccount.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using SuperSafeBank.Common; 6 | using SuperSafeBank.Common.EventBus; 7 | using SuperSafeBank.Domain.IntegrationEvents; 8 | 9 | namespace SuperSafeBank.Domain.Commands 10 | { 11 | public record CreateAccount : IRequest 12 | { 13 | public CreateAccount(Guid customerId, Guid accountId, Currency currency) 14 | { 15 | CustomerId = customerId; 16 | AccountId = accountId; 17 | Currency = currency ?? throw new ArgumentNullException(nameof(currency)); 18 | } 19 | 20 | public Guid CustomerId { get; } 21 | public Guid AccountId { get; } 22 | public Currency Currency { get; } 23 | } 24 | 25 | public class CreateAccountHandler : IRequestHandler 26 | { 27 | private readonly IAggregateRepository _customerEventsService; 28 | private readonly IAggregateRepository _accountEventsService; 29 | private readonly IEventProducer _eventProducer; 30 | 31 | public CreateAccountHandler(IAggregateRepository customerEventsService, IAggregateRepository accountEventsService, IEventProducer eventProducer) 32 | { 33 | _customerEventsService = customerEventsService; 34 | _accountEventsService = accountEventsService; 35 | _eventProducer = eventProducer; 36 | } 37 | 38 | public async Task Handle(CreateAccount command, CancellationToken cancellationToken) 39 | { 40 | var customer = await _customerEventsService.RehydrateAsync(command.CustomerId); 41 | if(null == customer) 42 | throw new ArgumentOutOfRangeException(nameof(CreateAccount.CustomerId), "invalid customer id"); 43 | 44 | var account = Account.Create(command.AccountId, customer, command.Currency); 45 | 46 | await _customerEventsService.PersistAsync(customer); 47 | await _accountEventsService.PersistAsync(account); 48 | 49 | var @event = new AccountCreated(Guid.NewGuid(), account.Id); 50 | await _eventProducer.DispatchAsync(@event, cancellationToken); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Azure.Tests/Integration/QueryHandlers/CustomersArchiveHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 3 | using SuperSafeBank.Service.Core.Azure.QueryHandlers; 4 | using SuperSafeBank.Service.Core.Common.Queries; 5 | using System; 6 | using System.ComponentModel; 7 | using System.Linq; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Xunit; 11 | 12 | namespace SuperSafeBank.Service.Core.Azure.Tests.Integration.QueryHandlers 13 | { 14 | [Trait("Category", "Integration")] 15 | [Category("Integration")] 16 | public class CustomersArchiveHandlerTests : IClassFixture 17 | { 18 | private readonly StorageTableFixutre _fixture; 19 | 20 | public CustomersArchiveHandlerTests(StorageTableFixutre fixture) 21 | { 22 | _fixture = fixture; 23 | } 24 | 25 | [Fact] 26 | public async Task Handle_should_return_empty_collection_when_items_not_available() 27 | { 28 | var query = new CustomersArchive(); 29 | 30 | var dbContext = _fixture.CreateTableClient(); 31 | var sut = new CustomersArchiveHandler(dbContext); 32 | var result = await sut.Handle(query, CancellationToken.None); 33 | result.Should().NotBeNull().And.BeEmpty(); 34 | } 35 | 36 | [Fact] 37 | public async Task Handle_should_return_results_when_available() 38 | { 39 | var entities = Enumerable.Range(0, 10) 40 | .Select(_ => 41 | { 42 | var customer = new CustomerArchiveItem(Guid.NewGuid(), "test", "customer"); 43 | return ViewTableEntity.Map(customer); 44 | }); 45 | var dbContext = _fixture.CreateTableClient(); 46 | foreach(var entity in entities) 47 | await dbContext.CustomersArchive.AddEntityAsync(entity); 48 | 49 | var query = new CustomersArchive(); 50 | 51 | var sut = new CustomersArchiveHandler(dbContext); 52 | var result = await sut.Handle(query, CancellationToken.None); 53 | result.Should().NotBeNullOrEmpty() 54 | .And.HaveCount(entities.Count()); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core.Azure/EventHandlers/CustomersArchiveHandler.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using MediatR; 3 | using Microsoft.Extensions.Logging; 4 | using SuperSafeBank.Common; 5 | using SuperSafeBank.Domain; 6 | using SuperSafeBank.Domain.IntegrationEvents; 7 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 8 | using SuperSafeBank.Service.Core.Common.Queries; 9 | using System; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | namespace SuperSafeBank.Worker.Core.Azure.EventHandlers 14 | { 15 | public class CustomersArchiveHandler : 16 | INotificationHandler 17 | { 18 | private readonly ILogger _logger; 19 | private readonly IViewsContext _dbContext; 20 | private readonly IAggregateRepository _customersRepo; 21 | 22 | public CustomersArchiveHandler(IViewsContext dbContext, IAggregateRepository customersRepo, ILogger logger) 23 | { 24 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 25 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 26 | _customersRepo = customersRepo ?? throw new ArgumentNullException(nameof(customersRepo)); 27 | } 28 | 29 | public async Task Handle(CustomerCreated @event, CancellationToken cancellationToken) 30 | { 31 | _logger.LogInformation("creating customer archive item for aggregate {AggregateId} ...", @event.CustomerId); 32 | 33 | var customer = await _customersRepo.RehydrateAsync(@event.CustomerId, cancellationToken); 34 | 35 | var customerView = new CustomerArchiveItem(customer.Id, customer.Firstname, customer.Lastname); 36 | 37 | var entity = ViewTableEntity.Map(customerView); 38 | var response = await _dbContext.CustomersArchive.UpsertEntityAsync(entity, mode: TableUpdateMode.Replace, cancellationToken: cancellationToken); 39 | if (response?.Status != 204) 40 | { 41 | var msg = $"an error has occurred while processing an event: {response.ReasonPhrase}"; 42 | throw new Exception(msg); 43 | } 44 | 45 | _logger.LogInformation($"created customer archive item {@event.CustomerId}"); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/EventHandlers/CustomersArchiveHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.Logging; 3 | using MongoDB.Driver; 4 | using SuperSafeBank.Common; 5 | using SuperSafeBank.Domain; 6 | using SuperSafeBank.Domain.IntegrationEvents; 7 | using SuperSafeBank.Service.Core.Common.Queries; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using System; 11 | 12 | namespace SuperSafeBank.Service.Core.Persistence.Mongo.EventHandlers 13 | { 14 | public class CustomersArchiveHandler : 15 | INotificationHandler 16 | { 17 | private readonly IQueryDbContext _db; 18 | private readonly IAggregateRepository _customersRepo; 19 | private readonly ILogger _logger; 20 | 21 | public CustomersArchiveHandler( 22 | IAggregateRepository customersRepo, 23 | IQueryDbContext db, 24 | ILogger logger) 25 | { 26 | _customersRepo = customersRepo ?? throw new ArgumentNullException(nameof(customersRepo)); 27 | _db = db ?? throw new ArgumentNullException(nameof(db)); 28 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 29 | } 30 | 31 | public async Task Handle(CustomerCreated @event, CancellationToken cancellationToken) 32 | { 33 | _logger.LogInformation("creating customer archive item for customer {CustomerId} ...", @event.CustomerId); 34 | 35 | var customer = await _customersRepo.RehydrateAsync(@event.CustomerId, cancellationToken); 36 | 37 | var filter = Builders.Filter 38 | .Eq(a => a.Id, customer.Id); 39 | 40 | var update = Builders.Update 41 | .Set(a => a.Id, customer.Id) 42 | .Set(a => a.Firstname, customer.Firstname) 43 | .Set(a => a.Lastname, customer.Lastname); 44 | 45 | await _db.Customers.UpdateOneAsync(filter, 46 | cancellationToken: cancellationToken, 47 | update: update, 48 | options: new UpdateOptions() { IsUpsert = true }); 49 | 50 | _logger.LogInformation($"created customer archive item for customer {@event.CustomerId}"); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Azure.Tests/Integration/QueryHandlers/CustomerByIdHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using SuperSafeBank.Domain; 3 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 4 | using SuperSafeBank.Service.Core.Azure.QueryHandlers; 5 | using SuperSafeBank.Service.Core.Common.Queries; 6 | using System; 7 | using System.ComponentModel; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Xunit; 11 | 12 | namespace SuperSafeBank.Service.Core.Azure.Tests.Integration.QueryHandlers 13 | { 14 | [Trait("Category", "Integration")] 15 | [Category("Integration")] 16 | public class CustomerByIdHandlerTests : IClassFixture 17 | { 18 | private readonly StorageTableFixutre _fixture; 19 | 20 | public CustomerByIdHandlerTests(StorageTableFixutre fixture) 21 | { 22 | _fixture = fixture; 23 | } 24 | 25 | [Fact] 26 | public async Task Handle_should_return_null_when_input_invalid() 27 | { 28 | var query = new CustomerById(Guid.Empty); 29 | 30 | var dbContext = _fixture.CreateTableClient(); 31 | var sut = new CustomerByIdHandler(dbContext); 32 | var result = await sut.Handle(query, CancellationToken.None); 33 | result.Should().BeNull(); 34 | } 35 | 36 | [Fact] 37 | public async Task Handle_should_return_model_when_input_valid() 38 | { 39 | var customer = new CustomerDetails(Guid.NewGuid(), "test", "customer", "test@email.com", null, Money.Zero(Currency.CanadianDollar)); 40 | var entity = ViewTableEntity.Map(customer); 41 | 42 | var dbContext = _fixture.CreateTableClient(); 43 | await dbContext.CustomersDetails.UpsertEntityAsync(entity); 44 | 45 | var query = new CustomerById(customer.Id); 46 | 47 | var sut = new CustomerByIdHandler(dbContext); 48 | var result = await sut.Handle(query, CancellationToken.None); 49 | result.Should().NotBeNull(); 50 | result.Firstname.Should().Be(customer.Firstname); 51 | result.Lastname.Should().Be(customer.Lastname); 52 | result.Email.Should().Be(customer.Email); 53 | result.TotalBalance.Should().Be(customer.TotalBalance); 54 | result.Accounts.Should().BeEmpty(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Worker.Core.Azure.Tests/Integration/EventHandlers/CustomersArchiveHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Domain; 4 | using SuperSafeBank.Domain.IntegrationEvents; 5 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 6 | using SuperSafeBank.Service.Core.Azure.Tests; 7 | using SuperSafeBank.Service.Core.Common.Queries; 8 | using SuperSafeBank.Worker.Core.Azure.EventHandlers; 9 | using System; 10 | using System.ComponentModel; 11 | using System.Text.Json; 12 | using System.Threading; 13 | using System.Threading.Tasks; 14 | using Xunit; 15 | 16 | namespace SuperSafeBank.Worker.Core.Azure.Tests.Integration.EventHandlers 17 | { 18 | [Trait("Category", "Integration")] 19 | [Category("Integration")] 20 | public class CustomersArchiveHandlerTests : IClassFixture 21 | { 22 | private readonly StorageTableFixutre _fixture; 23 | 24 | public CustomersArchiveHandlerTests(StorageTableFixutre fixture) 25 | { 26 | _fixture = fixture; 27 | } 28 | 29 | [Fact] 30 | public async Task Handle_CustomerCreated_should_create_view() 31 | { 32 | var dbContext = _fixture.CreateTableClient(); 33 | var repo = _fixture.CreateRepository(); 34 | 35 | var customer = Customer.Create(Guid.NewGuid(), "lorem", "ipsum", "test@email.com"); 36 | await repo.PersistAsync(customer); 37 | 38 | var @event = new CustomerCreated(Guid.NewGuid(), customer.Id); 39 | 40 | var logger = NSubstitute.Substitute.For>(); 41 | var sut = new CustomersArchiveHandler(dbContext, repo, logger); 42 | await sut.Handle(@event, CancellationToken.None); 43 | 44 | var key = customer.Id.ToString(); 45 | var response = await dbContext.CustomersArchive.GetEntityAsync(key, key); 46 | response.Should().NotBeNull(); 47 | response.Value.Should().NotBeNull(); 48 | var customerView = JsonSerializer.Deserialize(response.Value.Data); 49 | customerView.Should().NotBeNull(); 50 | customerView.Id.Should().Be(customer.Id); 51 | customerView.Firstname.Should().Be(customer.Firstname); 52 | customerView.Lastname.Should().Be(customer.Lastname); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Customer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using SuperSafeBank.Common.Models; 4 | using SuperSafeBank.Domain.DomainEvents; 5 | 6 | namespace SuperSafeBank.Domain 7 | { 8 | public record Customer : BaseAggregateRoot 9 | { 10 | private readonly HashSet _accounts = new(); 11 | 12 | private Customer() { } 13 | 14 | public Customer(Guid id, string firstname, string lastname, Email email) : base(id) 15 | { 16 | if(string.IsNullOrWhiteSpace(firstname)) 17 | throw new ArgumentNullException(nameof(firstname)); 18 | if (string.IsNullOrWhiteSpace(lastname)) 19 | throw new ArgumentNullException(nameof(lastname)); 20 | if (email is null) 21 | throw new ArgumentNullException(nameof(email)); 22 | 23 | this.Append(new CustomerEvents.CustomerCreated(this, firstname, lastname, email)); 24 | } 25 | 26 | public void AddAccount(Account account) 27 | { 28 | if (account is null) 29 | throw new ArgumentNullException(nameof(account)); 30 | 31 | if (_accounts.Contains(account.Id)) 32 | return; 33 | 34 | this.Append(new CustomerEvents.AccountAdded(this, account.Id)); 35 | } 36 | 37 | public string Firstname { get; private set; } 38 | public string Lastname { get; private set; } 39 | public Email Email { get; private set; } 40 | public IReadOnlyCollection Accounts => _accounts; 41 | 42 | protected override void When(IDomainEvent @event) 43 | { 44 | switch (@event) 45 | { 46 | case CustomerEvents.CustomerCreated c: 47 | this.Id = c.AggregateId; 48 | this.Firstname = c.Firstname; 49 | this.Lastname = c.Lastname; 50 | this.Email = c.Email; 51 | break; 52 | case CustomerEvents.AccountAdded aa: 53 | _accounts.Add(aa.AccountId); 54 | break; 55 | } 56 | } 57 | 58 | public static Customer Create(Guid customerId, string firstName, string lastName, string email) 59 | { 60 | return new Customer(customerId, firstName, lastName, new Email(email)); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/Registries/InfrastructureRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SuperSafeBank.Common.EventBus; 4 | using SuperSafeBank.Persistence.EventStore; 5 | using SuperSafeBank.Persistence.SQLServer; 6 | using SuperSafeBank.Service.Core.Persistence.Mongo; 7 | using SuperSafeBank.Transport.Kafka; 8 | 9 | namespace SuperSafeBank.Worker.Core.Registries 10 | { 11 | public static class InfrastructureRegistry 12 | { 13 | public static IServiceCollection RegisterInfrastructure(this IServiceCollection services, IConfiguration configuration) 14 | { 15 | var infraConfig = configuration.GetSection("infrastructure").Get(); 16 | 17 | var kafkaConnStr = configuration.GetConnectionString("kafka"); 18 | var eventsTopicName = configuration["eventsTopicName"]; 19 | var groupName = configuration["eventsTopicGroupName"]; 20 | var consumerConfig = new EventsConsumerConfig(kafkaConnStr, eventsTopicName, groupName); 21 | 22 | var mongoConnStr = configuration.GetConnectionString("mongo"); 23 | var mongoQueryDbName = configuration["queryDbName"]; 24 | var mongoConfig = new MongoConfig(mongoConnStr, mongoQueryDbName); 25 | 26 | return services.AddSingleton(consumerConfig) 27 | .AddSingleton(typeof(IEventConsumer), typeof(KafkaEventConsumer)) 28 | .AddMongoDb(mongoConfig) 29 | .RegisterAggregateStore(configuration, infraConfig); 30 | } 31 | 32 | private static IServiceCollection RegisterAggregateStore(this IServiceCollection services, IConfiguration config, InfrastructureConfig infraConfig) 33 | { 34 | if (infraConfig.AggregateStore == "EventStore") 35 | { 36 | var eventstoreConnStr = config.GetConnectionString("eventstore"); 37 | services.AddEventStorePersistence(eventstoreConnStr); 38 | } 39 | else if (infraConfig.AggregateStore == "SQLServer") 40 | { 41 | var sqlConnString = config.GetConnectionString("sql"); 42 | services.AddSQLServerPersistence(sqlConnString); 43 | } 44 | else throw new ArgumentOutOfRangeException($"invalid aggregate store type: {infraConfig.AggregateStore}"); 45 | 46 | return services; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/Models/BaseAggregateRoot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using System.Reflection; 6 | 7 | namespace SuperSafeBank.Common.Models 8 | { 9 | public abstract record BaseAggregateRoot : BaseEntity, IAggregateRoot 10 | where TA : class, IAggregateRoot 11 | { 12 | private readonly Queue> _events = new Queue>(); 13 | 14 | protected BaseAggregateRoot() { } 15 | 16 | protected BaseAggregateRoot(TKey id) : base(id) 17 | { 18 | } 19 | 20 | public IReadOnlyCollection> Events => _events.ToImmutableArray(); 21 | 22 | public long Version { get; private set; } 23 | 24 | public void ClearEvents() 25 | { 26 | _events.Clear(); 27 | } 28 | 29 | protected void Append(IDomainEvent @event) 30 | { 31 | _events.Enqueue(@event); 32 | 33 | this.When(@event); 34 | 35 | this.Version++; 36 | } 37 | 38 | protected abstract void When(IDomainEvent @event); 39 | 40 | #region Factory 41 | 42 | private static readonly ConstructorInfo CTor; 43 | 44 | static BaseAggregateRoot() 45 | { 46 | var aggregateType = typeof(TA); 47 | CTor = aggregateType.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, 48 | null, new Type[0], new ParameterModifier[0]); 49 | if (null == CTor) 50 | throw new InvalidOperationException($"Unable to find required private parameterless constructor for Aggregate of type '{aggregateType.Name}'"); 51 | } 52 | 53 | public static TA Create(IEnumerable> events) 54 | { 55 | if(null == events || !events.Any()) 56 | throw new ArgumentNullException(nameof(events)); 57 | var result = (TA)CTor.Invoke(new object[0]); 58 | 59 | var baseAggregate = result as BaseAggregateRoot; 60 | if (baseAggregate != null) 61 | foreach (var @event in events) 62 | baseAggregate.Append(@event); 63 | 64 | result.ClearEvents(); 65 | 66 | return result; 67 | } 68 | 69 | #endregion Factory 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/CustomerEmailsService.cs: -------------------------------------------------------------------------------- 1 | using MongoDB.Driver; 2 | using SuperSafeBank.Domain.Services; 3 | using System; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace SuperSafeBank.Service.Core.Persistence.Mongo 9 | { 10 | public class CustomerEmailsService : ICustomerEmailsService 11 | { 12 | private readonly IMongoDatabase _db; 13 | private readonly IMongoCollection _coll; 14 | 15 | public CustomerEmailsService(IMongoDatabase db) 16 | { 17 | _db = db ?? throw new ArgumentNullException(nameof(db)); 18 | _coll = _db.GetCollection("CustomerEmails"); 19 | } 20 | 21 | public async Task CreateAsync(string email, Guid customerId, CancellationToken cancellationToken = default) 22 | { 23 | var indexes = await (await _coll.Indexes.ListAsync()).ToListAsync(cancellationToken); 24 | if (!indexes.Any()) 25 | { 26 | var indexKeys = Builders.IndexKeys.Ascending(a => a.Email); 27 | var createIndex = new CreateIndexModel(indexKeys, new CreateIndexOptions() 28 | { 29 | Unique = true, 30 | Name = "email" 31 | }); 32 | await _coll.Indexes.CreateOneAsync(createIndex, cancellationToken: cancellationToken); 33 | } 34 | 35 | var update = Builders.Update 36 | .Set(a => a.Id, customerId) 37 | .Set(a => a.Email, email); 38 | 39 | await _coll.UpdateOneAsync(c => c.Email == email, update, options: new UpdateOptions() { IsUpsert = true }, cancellationToken: cancellationToken); 40 | } 41 | 42 | public async Task ExistsAsync(string email, CancellationToken cancellationToken = default) 43 | { 44 | var filter = Builders.Filter 45 | .Eq(a => a.Email, email); 46 | 47 | var count = await _coll.CountDocumentsAsync(filter, new CountOptions() 48 | { 49 | Limit = 1 50 | }, cancellationToken: cancellationToken); 51 | 52 | return count > 0; 53 | } 54 | } 55 | 56 | internal class CustomerEmail 57 | { 58 | public Guid Id { get; set; } 59 | public string Email { get; set; } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Persistence.Mongo/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using MongoDB.Bson; 3 | using MongoDB.Bson.Serialization; 4 | using MongoDB.Bson.Serialization.Serializers; 5 | using MongoDB.Driver; 6 | using System; 7 | 8 | namespace SuperSafeBank.Service.Core.Persistence.Mongo 9 | { 10 | public static class IServiceCollectionExtensions 11 | { 12 | /// 13 | /// https://stackoverflow.com/a/46470759/3279163 14 | /// 15 | internal class CustomSerializationProvider : IBsonSerializationProvider 16 | { 17 | private static readonly IBsonSerializer decimalSerializer = new DecimalSerializer(BsonType.Decimal128); 18 | private static readonly IBsonSerializer nullableSerializer = new NullableSerializer(decimalSerializer); 19 | private static readonly IBsonSerializer guidSerializer = new GuidSerializer(GuidRepresentation.Standard); 20 | 21 | public IBsonSerializer GetSerializer(Type type) 22 | { 23 | if (type == typeof(decimal)) return decimalSerializer; 24 | if (type == typeof(decimal?)) return nullableSerializer; 25 | if (type == typeof(Guid)) return guidSerializer; 26 | 27 | return null; // falls back to Mongo defaults 28 | } 29 | } 30 | 31 | public static IServiceCollection AddMongoDb(this IServiceCollection services, MongoConfig configuration) 32 | { 33 | //https://stackoverflow.com/questions/63443445/trouble-with-mongodb-c-sharp-driver-when-performing-queries-using-guidrepresenta 34 | BsonDefaults.GuidRepresentationMode = GuidRepresentationMode.V3; 35 | 36 | BsonSerializer.RegisterSerializationProvider(new CustomSerializationProvider()); 37 | 38 | return services.AddSingleton(ctx => new MongoClient(connectionString: configuration.ConnectionString)) 39 | .AddSingleton(ctx => 40 | { 41 | var client = ctx.GetRequiredService(); 42 | var database = client.GetDatabase(configuration.QueryDbName); 43 | return database; 44 | }).AddSingleton(); 45 | } 46 | } 47 | 48 | public record MongoConfig(string ConnectionString, string QueryDbName); 49 | } 50 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Common/JsonEventSerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Text; 7 | using System.Text.Json; 8 | using SuperSafeBank.Common.Models; 9 | 10 | namespace SuperSafeBank.Common; 11 | 12 | public class JsonEventSerializer : IEventSerializer 13 | { 14 | private readonly IEnumerable _assemblies; 15 | private ConcurrentDictionary _typesCache = new(); 16 | 17 | private static readonly JsonSerializerOptions _serializerOptions = new () 18 | { 19 | IgnoreReadOnlyFields = false, 20 | IgnoreReadOnlyProperties = false, 21 | IncludeFields = true, 22 | }; 23 | 24 | private static readonly Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings() 25 | { 26 | ConstructorHandling = Newtonsoft.Json.ConstructorHandling.AllowNonPublicDefaultConstructor, 27 | ContractResolver = new PrivateSetterContractResolver() 28 | }; 29 | 30 | public JsonEventSerializer(IEnumerable assemblies) 31 | { 32 | _assemblies = assemblies ?? new[] {Assembly.GetExecutingAssembly()}; 33 | } 34 | 35 | public IDomainEvent Deserialize(string type, ReadOnlySpan data) 36 | { 37 | Type eventType = ResolveEventType(type); 38 | // still not great support for deserializing immutable types with System.Text.Json 39 | var jsonData = System.Text.Encoding.UTF8.GetString(data); 40 | var result = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonData, eventType, JsonSerializerSettings); 41 | return (IDomainEvent)result; 42 | } 43 | 44 | public byte[] Serialize(IDomainEvent @event) 45 | { 46 | var json = JsonSerializer.Serialize((dynamic)@event, _serializerOptions); 47 | var data = Encoding.UTF8.GetBytes(json); 48 | return data; 49 | } 50 | 51 | private Type ResolveEventType(string type) 52 | { 53 | var eventType = _typesCache.GetOrAdd(type, _ => _assemblies.Select(a => a.GetType(type, false)) 54 | .FirstOrDefault(t => t != null) ?? Type.GetType(type)); 55 | if (null == eventType) 56 | throw new ArgumentOutOfRangeException(nameof(type), $"invalid event type: {type}"); 57 | return eventType; 58 | } 59 | } -------------------------------------------------------------------------------- /test/SuperSafeBank.Service.Core.Azure.Tests/Integration/QueryHandlers/AccountByIdHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using SuperSafeBank.Domain; 3 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 4 | using SuperSafeBank.Service.Core.Azure.QueryHandlers; 5 | using SuperSafeBank.Service.Core.Common.Queries; 6 | using System; 7 | using System.ComponentModel; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Xunit; 11 | 12 | namespace SuperSafeBank.Service.Core.Azure.Tests.Integration.QueryHandlers 13 | { 14 | [Trait("Category", "Integration")] 15 | [Category("Integration")] 16 | public class AccountByIdHandlerTests : IClassFixture 17 | { 18 | private readonly StorageTableFixutre _fixture; 19 | 20 | public AccountByIdHandlerTests(StorageTableFixutre fixture) 21 | { 22 | _fixture = fixture; 23 | } 24 | 25 | [Fact] 26 | public async Task Handle_should_return_null_when_input_invalid() 27 | { 28 | var query = new AccountById(Guid.Empty); 29 | 30 | var dbContext = _fixture.CreateTableClient(); 31 | var sut = new AccountByIdHandler(dbContext); 32 | var result = await sut.Handle(query, CancellationToken.None); 33 | result.Should().BeNull(); 34 | } 35 | 36 | [Fact] 37 | public async Task Handle_should_return_model_when_input_valid() 38 | { 39 | var account = new AccountDetails(Guid.NewGuid(), Guid.NewGuid(), "test", "customer", "test@email.com", Money.Zero(Currency.CanadianDollar)); 40 | var entity = ViewTableEntity.Map(account); 41 | 42 | var dbContext = _fixture.CreateTableClient(); 43 | await dbContext.Accounts.UpsertEntityAsync(entity); 44 | 45 | var query = new AccountById(account.Id); 46 | 47 | var sut = new AccountByIdHandler(dbContext); 48 | var result = await sut.Handle(query, CancellationToken.None); 49 | result.Should().NotBeNull(); 50 | result.OwnerEmail.Should().Be(account.OwnerEmail); 51 | result.OwnerLastName.Should().Be(account.OwnerLastName); 52 | result.OwnerFirstName.Should().Be(account.OwnerFirstName); 53 | result.OwnerId.Should().Be(account.OwnerId); 54 | result.Id.Should().Be(account.Id); 55 | result.Balance.Should().Be(account.Balance); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Core/Program.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Serilog; 6 | using Serilog.Sinks.Grafana.Loki; 7 | using SuperSafeBank.Common; 8 | using SuperSafeBank.Domain.DomainEvents; 9 | using SuperSafeBank.Domain.Services; 10 | using SuperSafeBank.Service.Core.Common; 11 | using SuperSafeBank.Service.Core.Common.EventHandlers; 12 | using SuperSafeBank.Service.Core.Persistence.Mongo.EventHandlers; 13 | using SuperSafeBank.Worker.Core.Registries; 14 | 15 | await Host.CreateDefaultBuilder(args) 16 | .ConfigureHostConfiguration(configurationBuilder => 17 | { 18 | configurationBuilder.AddCommandLine(args); 19 | }) 20 | .ConfigureAppConfiguration((ctx, builder) => 21 | { 22 | builder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 23 | .AddJsonFile($"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: true) 24 | .AddEnvironmentVariables(); 25 | }) 26 | .UseSerilog((ctx, cfg) => 27 | { 28 | cfg.Enrich.FromLogContext() 29 | .Enrich.WithProperty("Application", ctx.HostingEnvironment.ApplicationName) 30 | .Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName) 31 | .WriteTo.Console(); 32 | 33 | var connStr = ctx.Configuration.GetConnectionString("loki"); 34 | if(string.IsNullOrWhiteSpace(connStr)) 35 | throw new ArgumentNullException("loki connection string is not set"); 36 | cfg.WriteTo.GrafanaLoki(connStr); 37 | }) 38 | .ConfigureServices((hostContext, services) => 39 | { 40 | services.Scan(scan => 41 | { 42 | scan.FromAssembliesOf(typeof(AccountEventsHandler)) 43 | .RegisterHandlers(typeof(INotificationHandler<>)); 44 | }).Decorate(typeof(INotificationHandler<>), typeof(RetryDecorator<>)) 45 | .AddTransient() 46 | .AddScoped() 47 | .AddSingleton(new JsonEventSerializer(new[] 48 | { 49 | typeof(CustomerEvents.CustomerCreated).Assembly 50 | })) 51 | .RegisterInfrastructure(hostContext.Configuration) 52 | .RegisterWorker(); 53 | }) 54 | .Build() 55 | .RunAsync(); 56 | 57 | -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.Azure.Tests/SuperSafeBank.Persistence.Azure.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | fac86fdb-bf17-4bd9-9a96-a6738b5e5a9e 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | true 18 | PreserveNewest 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | all 33 | runtime; build; native; contentfiles; analyzers; buildtransitive 34 | 35 | 36 | all 37 | runtime; build; native; contentfiles; analyzers; buildtransitive 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using MediatR; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using SuperSafeBank.Domain.Commands; 7 | using SuperSafeBank.Domain.Services; 8 | using SuperSafeBank.Persistence.Azure; 9 | using SuperSafeBank.Service.Core.Azure.Common.Persistence; 10 | using SuperSafeBank.Service.Core.Azure.QueryHandlers; 11 | using SuperSafeBank.Service.Core.Azure.Services; 12 | using SuperSafeBank.Service.Core.Common; 13 | using SuperSafeBank.Transport.Azure; 14 | using System.Reflection; 15 | 16 | var builder = new HostBuilder(); 17 | 18 | await builder.ConfigureFunctionsWorkerDefaults() 19 | .ConfigureHostConfiguration(builder => 20 | { 21 | builder.AddUserSecrets(Assembly.GetExecutingAssembly(), true); 22 | }) 23 | .ConfigureServices((ctx, services) => 24 | { 25 | var eventsRepositoryConfig = new EventsRepositoryConfig(ctx.Configuration["EventsStorage"], ctx.Configuration["EventTablesPrefix"]); 26 | var eventProducerConfig = new EventProducerConfig(ctx.Configuration["EventsBus"], ctx.Configuration["TopicName"]); 27 | 28 | services.AddScoped() 29 | .Scan(scan => 30 | { 31 | scan.FromAssembliesOf(typeof(CustomerByIdHandler), typeof(CreateCustomerHandler)) 32 | .RegisterHandlers(typeof(IRequestHandler<>)) 33 | .RegisterHandlers(typeof(IRequestHandler<,>)) 34 | .RegisterHandlers(typeof(INotificationHandler<>)); 35 | }) 36 | .AddTransient(provider => 37 | { 38 | var connStr = ctx.Configuration["EventsStorage"]; 39 | var client = new TableClient(connStr, "CustomerEmails"); 40 | client.CreateIfNotExists(); 41 | return new CustomerEmailsService(client); 42 | }) 43 | .AddTransient() 44 | .AddSingleton(provider => 45 | { 46 | var connStr = ctx.Configuration["QueryModelsStorage"]; 47 | var tablesPrefix = ctx.Configuration["QueryModelsTablePrefix"]; 48 | return new ViewsContext(connStr, tablesPrefix); 49 | }).AddAzurePersistence(eventsRepositoryConfig) 50 | .AddAzureTransport(eventProducerConfig); 51 | }) 52 | .Build() 53 | .RunAsync(); -------------------------------------------------------------------------------- /test/SuperSafeBank.Persistence.SQLServer.Tests/Integration/DbFixture.cs: -------------------------------------------------------------------------------- 1 | using Dapper; 2 | using Microsoft.Data.SqlClient; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace SuperSafeBank.Persistence.SQLServer.Tests.Integration 6 | { 7 | public class DbFixture : IAsyncLifetime 8 | { 9 | private readonly string _baseConnStr; 10 | private readonly Queue _dbNames = new(); 11 | private readonly static Random _rand = new(); 12 | 13 | public DbFixture() 14 | { 15 | var configuration = new ConfigurationBuilder() 16 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) 17 | .Build(); 18 | 19 | _baseConnStr = configuration.GetConnectionString("sql"); 20 | if (string.IsNullOrWhiteSpace(_baseConnStr)) 21 | throw new ArgumentException("invalid connection string"); 22 | 23 | SqlMapper.AddTypeHandler(new ByteArrayTypeHandler()); 24 | } 25 | 26 | public async Task CreateDbConnectionStringProviderAsync() 27 | { 28 | var dbName = $"supersafebank_test_db_{Guid.NewGuid()}"; 29 | var createDbConnStr = $"{_baseConnStr};Database=master"; 30 | 31 | using var createDbConn = new SqlConnection(createDbConnStr); 32 | await createDbConn.OpenAsync(); 33 | using var createDbCmd = new SqlCommand($"CREATE DATABASE [{dbName}];", createDbConn); 34 | await createDbCmd.ExecuteNonQueryAsync(); 35 | 36 | _dbNames.Enqueue(dbName); 37 | 38 | var connectionString = $"{_baseConnStr};Database={dbName}"; 39 | 40 | return new SqlConnectionStringProvider(connectionString); 41 | } 42 | 43 | public Task InitializeAsync() 44 | => Task.CompletedTask; 45 | 46 | public async Task DisposeAsync() 47 | { 48 | var connStr = $"{_baseConnStr};Database=master;"; 49 | using var conn = new SqlConnection(connStr); 50 | await conn.OpenAsync(); 51 | 52 | while (_dbNames.Any()) 53 | { 54 | var dbName = _dbNames.Dequeue(); 55 | 56 | try 57 | { 58 | var dropDbSql = $"alter database [{dbName}] set single_user with rollback immediate; DROP DATABASE [{dbName}];"; 59 | using var dropCmd = new SqlCommand(dropDbSql, conn); 60 | await dropCmd.ExecuteNonQueryAsync(); 61 | } 62 | catch (Exception ex) 63 | { 64 | Console.WriteLine($"unable to drop db '{dbName}' : {ex.Message}"); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Service.Core.Azure/Triggers/CustomerTriggers.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Azure.Functions.Worker; 3 | using Microsoft.Azure.Functions.Worker.Http; 4 | using SuperSafeBank.Common; 5 | using SuperSafeBank.Domain.Commands; 6 | using SuperSafeBank.Service.Core.Azure.DTOs; 7 | using SuperSafeBank.Service.Core.Common.Queries; 8 | using System; 9 | using System.Text.Json; 10 | using System.Threading.Tasks; 11 | 12 | namespace SuperSafeBank.Service.Core.Azure.Triggers 13 | { 14 | public class CustomerTriggers 15 | { 16 | private readonly IMediator _mediator; 17 | 18 | public CustomerTriggers(IMediator mediator) 19 | { 20 | _mediator = mediator ?? throw new System.ArgumentNullException(nameof(mediator)); 21 | } 22 | 23 | [Function(nameof(GetCustomers))] 24 | public async Task GetCustomers([HttpTrigger(AuthorizationLevel.Function, "get", Route = "customers")] HttpRequestData req) 25 | { 26 | var query = new CustomersArchive(); 27 | var results = await _mediator.Send(query); 28 | var response = req.CreateResponse(System.Net.HttpStatusCode.OK); 29 | await response.WriteAsJsonAsync(results); 30 | return response; 31 | } 32 | 33 | [Function(nameof(CreateCustomer))] 34 | public async Task CreateCustomer([HttpTrigger(AuthorizationLevel.Function, "post", Route = "customers")] HttpRequestData req) 35 | { 36 | var dto = await JsonSerializer.DeserializeAsync(req.Body, JsonSerializerDefaultOptions.Defaults); 37 | var command = new CreateCustomer(Guid.NewGuid(), dto.FirstName, dto.LastName, dto.Email); 38 | await _mediator.Send(command); 39 | 40 | var response = req.CreateResponse(System.Net.HttpStatusCode.Created); 41 | response.Headers.Add("Location", $"/customers/{command.CustomerId}"); 42 | await response.WriteAsJsonAsync(new { id = command.CustomerId }); 43 | 44 | return response; 45 | } 46 | 47 | [Function(nameof(GetCustomerById))] 48 | public async Task GetCustomerById([HttpTrigger(AuthorizationLevel.Function, "get", Route = "customers/{customerId:guid}")] HttpRequestData req, 49 | Guid customerId) 50 | { 51 | var query = new CustomerById(customerId); 52 | var result = await _mediator.Send(query); 53 | if (result is null) 54 | return req.CreateResponse(System.Net.HttpStatusCode.NotFound); 55 | 56 | var response = req.CreateResponse(System.Net.HttpStatusCode.OK); 57 | await response.WriteAsJsonAsync(result); 58 | return response; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/SuperSafeBank.Persistence.EvenireDB/AggregateRepository.cs: -------------------------------------------------------------------------------- 1 | using EvenireDB.Client; 2 | using EvenireDB.Common; 3 | using SuperSafeBank.Common; 4 | using SuperSafeBank.Common.Models; 5 | 6 | namespace SuperSafeBank.Persistence.EvenireDB; 7 | 8 | internal class AggregateRepository : IAggregateRepository 9 | where TA : class, IAggregateRoot 10 | { 11 | private readonly IEventsClient _client; 12 | private readonly IEventSerializer _eventDeserializer; 13 | 14 | public AggregateRepository(IEventsClient client, IEventSerializer eventDeserializer) 15 | { 16 | _client = client ?? throw new ArgumentNullException(nameof(client)); 17 | _eventDeserializer = eventDeserializer ?? throw new ArgumentNullException(nameof(eventDeserializer)); 18 | } 19 | 20 | public async Task PersistAsync(TA aggregateRoot, CancellationToken cancellationToken = default) 21 | { 22 | ArgumentNullException.ThrowIfNull(aggregateRoot); 23 | 24 | if (!aggregateRoot.Events.Any()) 25 | return; 26 | 27 | if (aggregateRoot.Id is not Guid streamKey) 28 | throw new NotSupportedException("only Guid keys are currently supported."); 29 | 30 | string streamType = GetStreamType(); 31 | var streamId = new StreamId(streamKey, streamType); 32 | 33 | var events = aggregateRoot.Events.Select(evt => Event.Create(evt, evt.GetType().FullName)) 34 | .ToArray(); 35 | 36 | await _client.AppendAsync(streamId, events, cancellationToken) 37 | .ConfigureAwait(false); 38 | 39 | aggregateRoot.ClearEvents(); 40 | } 41 | 42 | public async Task RehydrateAsync(TKey key, CancellationToken cancellationToken = default) 43 | { 44 | if (key is not Guid streamKey) 45 | throw new NotSupportedException("only Guid keys are currently supported."); 46 | 47 | string streamType = GetStreamType(); 48 | var streamId = new StreamId(streamKey, streamType); 49 | 50 | var events = new List>(); 51 | await foreach (var @event in _client.ReadAsync(streamId, StreamPosition.Start, Direction.Forward, cancellationToken).ConfigureAwait(false)) 52 | { 53 | var mappedEvent = Map(@event); 54 | events.Add(mappedEvent); 55 | } 56 | 57 | if (!events.Any()) 58 | return null; 59 | 60 | var result = BaseAggregateRoot.Create(events.OrderBy(e => e.AggregateVersion)); 61 | 62 | return result; 63 | } 64 | 65 | private IDomainEvent Map(Event resolvedEvent) 66 | => _eventDeserializer.Deserialize(resolvedEvent.Type, resolvedEvent.Data.Span); 67 | 68 | private static string GetStreamType() 69 | => typeof(TA).Name; 70 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Domain/Commands/CreateCustomer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using MediatR; 5 | using SuperSafeBank.Common; 6 | using SuperSafeBank.Common.EventBus; 7 | using SuperSafeBank.Domain.IntegrationEvents; 8 | using SuperSafeBank.Domain.Services; 9 | 10 | namespace SuperSafeBank.Domain.Commands 11 | { 12 | public record CreateCustomer : IRequest 13 | { 14 | public CreateCustomer(Guid id, string firstName, string lastName, string email) 15 | { 16 | this.CustomerId = id; 17 | this.FirstName = firstName; 18 | this.LastName = lastName; 19 | this.Email = email; 20 | } 21 | 22 | public Guid CustomerId {get;} 23 | public string FirstName { get; } 24 | public string LastName { get; } 25 | public string Email { get; } 26 | } 27 | 28 | public class CreateCustomerHandler : IRequestHandler 29 | { 30 | private readonly IAggregateRepository _eventsService; 31 | private readonly ICustomerEmailsService _customerEmailsRepository; 32 | private readonly IEventProducer _eventProducer; 33 | 34 | public CreateCustomerHandler( 35 | IAggregateRepository eventsService, 36 | ICustomerEmailsService customerEmailsRepository, 37 | IEventProducer eventProducer) 38 | { 39 | _eventsService = eventsService ?? throw new ArgumentNullException(nameof(eventsService)); 40 | _customerEmailsRepository = customerEmailsRepository ?? throw new ArgumentNullException(nameof(customerEmailsRepository)); 41 | _eventProducer = eventProducer ?? throw new ArgumentNullException(nameof(eventProducer)); 42 | } 43 | 44 | public async Task Handle(CreateCustomer command, CancellationToken cancellationToken) 45 | { 46 | if(string.IsNullOrWhiteSpace(command.Email)) 47 | throw new ValidationException("Invalid email address", new ValidationError(nameof(CreateCustomer.Email), "email cannot be empty")); 48 | 49 | if (await _customerEmailsRepository.ExistsAsync(command.Email)) 50 | throw new ValidationException("Duplicate email address", new ValidationError(nameof(CreateCustomer.Email), $"email '{command.Email}' already exists")); 51 | 52 | var customer = Customer.Create(command.CustomerId, command.FirstName, command.LastName, command.Email); 53 | await _eventsService.PersistAsync(customer); 54 | await _customerEmailsRepository.CreateAsync(command.Email, customer.Id); 55 | 56 | var @event = new CustomerCreated(Guid.NewGuid(), command.CustomerId); 57 | await _eventProducer.DispatchAsync(@event, cancellationToken); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/SuperSafeBank.Worker.Notifications/AccountEventsWorker.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | using Microsoft.Extensions.Logging; 3 | using SuperSafeBank.Common.EventBus; 4 | using SuperSafeBank.Domain.IntegrationEvents; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace SuperSafeBank.Worker.Notifications 10 | { 11 | public class AccountEventsWorker : BackgroundService 12 | { 13 | private readonly IEventConsumer _consumer; 14 | private readonly ILogger _logger; 15 | private readonly INotificationsService _notificationsService; 16 | private readonly INotificationsFactory _notificationsFactory; 17 | 18 | public AccountEventsWorker(INotificationsFactory notificationsFactory, 19 | INotificationsService notificationsService, 20 | IEventConsumer consumer, 21 | ILogger logger) 22 | { 23 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 24 | _notificationsService = notificationsService ?? throw new ArgumentNullException(nameof(notificationsService)); 25 | _notificationsFactory = notificationsFactory ?? throw new ArgumentNullException(nameof(notificationsFactory)); 26 | 27 | _consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); 28 | _consumer.EventReceived += OnEventReceived; 29 | _consumer.ExceptionThrown += OnExceptionThrown; 30 | } 31 | 32 | private async Task OnEventReceived(object s, IIntegrationEvent @event) 33 | { 34 | var notification = @event switch 35 | { 36 | AccountCreated newAccount => await _notificationsFactory.CreateNewAccountNotificationAsync(newAccount.AccountId), 37 | TransactionHappened transaction => await _notificationsFactory.CreateTransactionNotificationAsync(transaction.AccountId), 38 | _ => (Notification)null 39 | }; 40 | 41 | if (null != notification) 42 | await _notificationsService.DispatchAsync(notification); 43 | } 44 | 45 | private void OnExceptionThrown(object s, Exception ex) 46 | { 47 | _logger.LogError(ex, $"an exception has occurred while consuming a message: {ex.Message}"); 48 | } 49 | 50 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 51 | { 52 | await _consumer.StartConsumeAsync(stoppingToken); 53 | } 54 | 55 | public override Task StopAsync(CancellationToken cancellationToken) 56 | { 57 | _consumer.EventReceived -= OnEventReceived; 58 | _consumer.ExceptionThrown -= OnExceptionThrown; 59 | 60 | return base.StopAsync(cancellationToken); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | sqlServer: 5 | image: "mcr.microsoft.com/mssql/server:latest" 6 | container_name: sqlServer 7 | environment: 8 | SA_PASSWORD: "Sup3r_Lam3_P4ss" 9 | ACCEPT_EULA: "Y" 10 | ports: 11 | - "1433:1433" 12 | 13 | eventstore_db: 14 | image: eventstore/eventstore:22.10.2-buster-slim 15 | environment: 16 | - EVENTSTORE_CLUSTER_SIZE=1 17 | - EVENTSTORE_RUN_PROJECTIONS=All 18 | - EVENTSTORE_START_STANDARD_PROJECTIONS=true 19 | - EVENTSTORE_EXT_TCP_PORT=1113 20 | - EVENTSTORE_HTTP_PORT=2113 21 | - EVENTSTORE_INSECURE=true 22 | - EVENTSTORE_ENABLE_EXTERNAL_TCP=true 23 | - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true 24 | ports: 25 | - 1113:1113 26 | - 2113:2113 27 | restart: always 28 | 29 | zookeeper: 30 | image: 'bitnami/zookeeper:3' 31 | ports: 32 | - '2181:2181' 33 | volumes: 34 | - 'zookeeper_data:/bitnami' 35 | environment: 36 | - ALLOW_ANONYMOUS_LOGIN=yes 37 | tmpfs: "/datalog" 38 | 39 | kafka: 40 | image: 'bitnami/kafka:2' 41 | ports: 42 | - '9092:9092' 43 | - '29092:29092' 44 | volumes: 45 | - 'kafka_data:/bitnami' 46 | environment: # https://rmoff.net/2018/08/02/kafka-listeners-explained/ 47 | - KAFKA_LISTENERS=LISTENER_BOB://kafka:29092,LISTENER_FRED://kafka:9092 48 | - KAFKA_ADVERTISED_LISTENERS=LISTENER_BOB://kafka:29092,LISTENER_FRED://localhost:9092 49 | - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=LISTENER_BOB:PLAINTEXT,LISTENER_FRED:PLAINTEXT 50 | - KAFKA_INTER_BROKER_LISTENER_NAME=LISTENER_BOB 51 | - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 52 | - ALLOW_PLAINTEXT_LISTENER=yes 53 | depends_on: 54 | - zookeeper 55 | 56 | mongodb: 57 | image: mongo:latest 58 | environment: 59 | MONGO_INITDB_ROOT_USERNAME: root 60 | MONGO_INITDB_ROOT_PASSWORD: password 61 | ports: 62 | - 27017:27017 63 | volumes: 64 | - mongodb_data:/data/db 65 | 66 | loki: 67 | image: grafana/loki:master 68 | ports: 69 | - "3100:3100" 70 | command: -config.file=/etc/loki/local-config.yaml 71 | networks: 72 | - loki 73 | 74 | grafana: 75 | image: grafana/grafana:master 76 | ports: 77 | - "3000:3000" 78 | networks: 79 | - loki 80 | 81 | azurite: 82 | image: mcr.microsoft.com/azure-storage/azurite 83 | restart: always 84 | command: "azurite --blobHost 0.0.0.0 --blobPort 10000 --queueHost 0.0.0.0 --queuePort 10001 --tableHost 0.0.0.0 --tablePort 10002" 85 | ports: 86 | - "10000:10000" 87 | - "10001:10001" 88 | - "10002:10002" 89 | 90 | volumes: 91 | zookeeper_data: 92 | driver: local 93 | kafka_data: 94 | driver: local 95 | mongodb_data: 96 | driver: local 97 | 98 | networks: 99 | loki: --------------------------------------------------------------------------------