Overview
In the development of internal tools, authentication is often overlooked. If you begin building real SaaS platforms used by multiple companies, everything changes. You might start with a simple login form, store passwords in a database, add roles, and move on. Managing thousands of users, managing multiple organizations, maintaining legal compliance, passing security audits, addressing data privacy requirements, and ensuring high availability are all challenges you face at this stage. Authentication alone is no longer sufficient. You need centralized identity, strong security, tenant isolation, auditability, and scalability instead.
With this article, I will guide you through designing a production-ready customer management system using C# 14, ASP.NET Core, EF Core, and Keycloak. Our focus will be on practical best practices rather than just theory.
The Business Scenario
An example business scenario describes the development of a SaaS platform called ZiggyCRM that is designed to be used by several companies. Each company uses the platform to manage its customers independently.
Each company has its own staff, meaning employees or team members who will interact with the platform.
Each company manages its own set of customers, which are separate from those of other companies using the platform.
Each organization has its own administrators, who are responsible for overseeing and configuring the platform.
Each company pays its own subscription fee, indicating that the platform operates on a per-company billing model rather than a shared or collective approach.
The same application is used by all companies, which means the platform must maintain strict separation of data and functionality to ensure that each company's operations are isolated from the others.
A fundamental requirement of the platform's architecture is that Company A never sees Company B's data. This emphasizes the importance of strong data isolation. The platform's design must prioritize this rule to prevent accidental or unauthorized access to the information of another company—from its data storage to its access controls. In order to ensure security, privacy, and compliance, this principle is central to the architecture.
Why Use Keycloak Instead of Custom Authentication?
Keycloak offers several advantages over ASP.NET Identity in large systems. ASP.NET Identity is a solid choice, but it has limitations in large systems.
Users can log in once and access multiple systems with Single Sign-On (SSO).
It enhances security by requiring additional verification with Multi-Factor Authentication (MFA).
Secure password requirements can be enforced through password policies.
Authentication through external providers like Google or Facebook is supported by social login.
For centralized identity management, Federation integrates with Active Directory (AD) or LDAP.
A centralized security management system simplifies the administration of user access and permissions.
Keycloak eliminates the need for reinventing security. It aligns with best practices for large-scale, secure applications. Instead of building a custom authentication system, you can integrate a battle-tested platform widely used in enterprise systems.
High-Level Architecture

Here is a high-level diagram of the architecture:
A browser or mobile app interacts with the system.
Identity is handled by Keycloak.
Authorization and business logic are managed by the ASP.NET Core API.
Data is the focus of EF Core.
Storage is provided by SQL Server.
There is no overlap or confusion between the layers because each layer has a clear responsibility:
Identity is handled by Keycloak.
Business logic and authorization are managed by APIs.
Data is handled by EF Core.
Data is stored in a database.
It ensures a clear and well-defined separation of responsibilities.
Setting Up Keycloak Locally on Windows 11
Keycloak must first be run on our local machine before we can connect our ASP.NET Core application to it.
With Windows 11, Docker is the easiest and most reliable way to avoid complicated manual installation and provide an environment that behaves almost exactly like production.
Prerequisites
The following should be installed on your computer:
The 64-bit version of Windows 11
The Docker desktop application
Windows Terminal or PowerShell
Docker Desktop can be downloaded from:
https://www.docker.com/products/docker-desktop/
Open PowerShell after installation and check:
docker --version
Docker is ready if you see a version number.
Running Keycloak with Docker
Run the following command in PowerShell:
docker run -d `
--name keycloak `
-p 9090:8080 `
-e KEYCLOAK_ADMIN=admin `
-e KEYCLOAK_ADMIN_PASSWORD=Admin123! `
quay.io/keycloak/keycloak:latest `
start-dev
The following is the result of this command:
Keycloak can be downloaded
An admin user is created
Keycloak is run in development mode
Port 9090 is made available
Keycloak will start after a few seconds.
Opening the Admin Console
Go to the following website in your browser:
http://localhost:9090
The admin panel consists of:
http://localhost:9090/admin
Login with:
Username: admin
Password: Admin123!
The Keycloak dashboard should now appear.
Checking That Keycloak Is Running
Run the following commands to ensure everything is working:
docker ps
A container named keycloak should appear.
Check the logs if something looks wrong:
docker logs -f keycloak
Creating a Realm for Our System
Users and clients are separated by realms. Each system should have its own realm.
The Admin Console shows:
Select Master from the menu
Create a realm by selecting it
Please enter:
Name: ziggy
Click Create
ZiggyCRM will use this realm.
Creating an API Client
Our ASP.NET API is now registered.
Open Clients
Click Create Client
Enter:
Client ID: ziggy-api
Select OpenID Connect
Click Save
Enable:
Client Authentication
Authorization
Save it again.
We will use the Client Secret in our API later.
Creating User Roles
Here's what goes on inside Ziggy:
Open Realm Roles
Create these roles:
admin
manager
user
Our application will be controlled by these roles.
Creating Users for Each Company
The next step is to create users.
Example for Company A:
Username: admin-companyA
Email: [email protected]
Assign roles:
admin
manager
Add a user attribute:
Key: tenant_id
Value: companyA
The system uses this information to identify the user's company.
Adding Tenant Information to Tokens
The tenant_id should appear inside JWT tokens.
To do this:
Open Client Scopes
Add a new Mapper
Configure:
Name: tenant-id
Type: User Attribute
User Attribute: tenant_id
Token Claim Name: tenant_id
Enable it for:
Access Token
ID Token
User Info
Tenant information will now be included in every token.
Connecting ASP.NET Core in Development
Appsettings.Development.json contains the following information:
"Keycloak": {
"Authority": "http://localhost:9090/realms/ziggy",
"ClientId": "ziggy-api",
"ClientSecret": "your-secret"
}
In Program.cs (development only):
options.RequireHttpsMetadata = false;
In production, HTTPS should not be disabled.
Stopping and Restarting Keycloak
To stop Keycloak:
docker stop keycloak
To remove it:
docker rm -f keycloak
You can restart Docker by running the command again.
Your environment can be reset easily this way.
Why This Setup Works Well
As a result of running Keycloak with Docker, we are able to:
Every developer has the same setup
Installation is easy
Resetting quickly
Configuration issues are fewer
Matching production better
Teams can work faster and avoid security mistakes this way.
This way, we set up Keycloak before writing any application code, keeping our system secure, consistent, and easy to maintain.
Step 1: Configuring JWT Authentication
Our API only trusts Keycloak tokens.
In Program.cs:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text.Json;
using Ziggy.CRM.Api.Extensions;
using Ziggy.CRM.Api.Security;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Application.Services.Auth;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Infrastructure.Extensions;
using Ziggy.CRM.Infrastructure.Tenancy;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<IKeycloakAuthService, KeycloakAuthService>();
builder.Services.AddTransient<IClaimsTransformation, KeycloakRoleClaimsTransformer>();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateCustomerCommand>());
builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
var authority = builder.Configuration["Jwt:Authority"];
options.Authority = authority;
options.RequireHttpsMetadata = false; options.SaveToken = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudiences = new[] { "account", "ziggy-api" },
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
NameClaimType = "preferred_username",
RoleClaimType = ClaimTypes.Role,
ClockSkew = TimeSpan.FromMinutes(2)
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var authHeader = context.Request.Headers.Authorization.ToString();
Console.WriteLine($"Authorization header received: {authHeader}");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
Console.WriteLine($"AUTH FAILED: {context.Exception}");
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
Console.WriteLine("TOKEN VALIDATED");
if (context.Principal?.Identity is ClaimsIdentity identity)
{
var realmAccess = context.Principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrWhiteSpace(realmAccess))
{
using var doc = JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
rolesElement.ValueKind == JsonValueKind.Array)
{
foreach (var role in rolesElement.EnumerateArray())
{
var roleValue = role.GetString();
if (!string.IsNullOrWhiteSpace(roleValue) &&
!identity.HasClaim(ClaimTypes.Role, roleValue))
{
identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
}
}
}
}
var resourceAccess = context.Principal.FindFirst("resource_access")?.Value;
if (!string.IsNullOrWhiteSpace(resourceAccess))
{
using var doc = JsonDocument.Parse(resourceAccess);
if (doc.RootElement.TryGetProperty("ziggy-api", out var apiClient) &&
apiClient.TryGetProperty("roles", out var clientRoles) &&
clientRoles.ValueKind == JsonValueKind.Array)
{
foreach (var role in clientRoles.EnumerateArray())
{
var roleValue = role.GetString();
if (!string.IsNullOrWhiteSpace(roleValue) &&
!identity.HasClaim(ClaimTypes.Role, roleValue))
{
identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
}
}
}
}
var tenantId =
context.Principal.FindFirst("tenant-id")?.Value ??
context.Principal.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(tenantId) &&
!identity.HasClaim("tenant-id", tenantId))
{
identity.AddClaim(new Claim("tenant-id", tenantId));
}
}
foreach (var claim in context.Principal!.Claims)
{
Console.WriteLine($"{claim.Type} = {claim.Value}");
}
return Task.CompletedTask;
},
OnChallenge = context =>
{
Console.WriteLine(
$"JWT CHALLENGE: error={context.Error}, " +
$"description={context.ErrorDescription}"
);
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("UserAccess", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("tenant-id");
});
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
options.AddPolicy("ManagerOrAdmin", policy => policy.RequireRole("manager", "admin"));
});
var app = builder.Build();
app.UseGlobalExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Best practice:
Validate the issuer at all times
Audience validation is always important
HTTPS should always be required
In production, these should never be disabled.
Step 2: Understanding the JWT Token
The user receives a token that looks like this after logging in:
{
"sub": "user-3429",
"email": "[email protected]",
"tenant_id": "companyZiggy",
"roles": ["admin", "manager"]
}
The token tells us everything we need to know:
The user's identity
The company they work for
Permissions they have
This information will be used everywhere.
Step 3: Creating a Proper Domain Model
Enterprise systems often make the mistake of letting EF Core entities become data bags.
Business rules should be expressed in your domain.
AuditableEntity.cs
The following is a proper AuditableEntity entity:
namespace Ziggy.CRM.Domain.Common;
public abstract class AuditableEntity
{
/// <summary>
/// UTC timestamp when the entity was created.
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// User ID or name of the user who created the entity.
/// </summary>
public string CreatedBy { get; set; } = default!;
/// <summary>
/// UTC timestamp when the entity was last updated.
/// </summary>
public DateTime? UpdatedAtUtc { get; set; }
/// <summary>
/// User ID or name of the user who last updated the entity.
/// </summary>
public string? UpdatedBy { get; set; }
/// <summary>
/// Indicates if the entity has been soft deleted.
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// UTC timestamp when the entity was deleted.
/// </summary>
public DateTime? DeletedAtUtc { get; set; }
/// <summary>
/// User ID or name of the user who deleted the entity.
/// </summary>
public string? DeletedBy { get; set; }
}
Customer.cs
The following is a proper Customer entity:
using Ziggy.CRM.Domain.Common;
namespace Ziggy.CRM.Domain.Entities;
public class Customer : AuditableEntity
{
public Guid Id { get; private set; }
public string TenantId { get; private set; } = default!;
public string FirstName { get; private set; } = default!;
public string LastName { get; private set; } = default!;
public string Email { get; private set; } = default!;
public bool IsActive { get; private set; }
public ICollection<Order> Orders { get; private set; } = new List<Order>();
private Customer() { }
public static Customer Create(string tenantId, string firstName, string lastName, string email)
{
if (string.IsNullOrWhiteSpace(tenantId))
throw new ArgumentException("Tenant id is required.", nameof(tenantId));
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException("First name is required.", nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException("Last name is required.", nameof(lastName));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required.", nameof(email));
return new Customer
{
Id = Guid.NewGuid(),
TenantId = tenantId.Trim(),
FirstName = firstName.Trim(),
LastName = lastName.Trim(),
Email = email.Trim().ToLowerInvariant(),
IsActive = true,
CreatedAtUtc = DateTime.UtcNow,
CreatedBy = "system"
};
}
public void Update(string firstName, string lastName, string email)
{
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException("First name is required.", nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException("Last name is required.", nameof(lastName));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required.", nameof(email));
FirstName = firstName.Trim();
LastName = lastName.Trim();
Email = email.Trim().ToLowerInvariant();
}
public void Deactivate() => IsActive = false;
}
Product.cs
The following is a proper Product entity:
using Ziggy.CRM.Domain.Common;
namespace Ziggy.CRM.Domain.Entities;
public class Product : AuditableEntity
{
private Product() { }
public Guid Id { get; private set; }
public string TenantId { get; private set; } = default!;
public string Name { get; private set; } = default!;
public string Sku { get; private set; } = default!;
public decimal UnitPrice { get; private set; }
public string Currency { get; private set; } = "GBP";
public ICollection<Order> Orders { get; private set; } = new List<Order>();
public static Product Create(
string tenantId,
string name,
string sku,
decimal unitPrice,
string currency = "GBP")
{
if (string.IsNullOrWhiteSpace(tenantId))
throw new ArgumentException("Tenant id is required.", nameof(tenantId));
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Product name is required.", nameof(name));
if (string.IsNullOrWhiteSpace(sku))
throw new ArgumentException("SKU is required.", nameof(sku));
if (unitPrice < 0)
throw new ArgumentOutOfRangeException(nameof(unitPrice), "Price cannot be negative.");
return new Product
{
Id = Guid.NewGuid(),
TenantId = tenantId.Trim(),
Name = name.Trim(),
Sku = sku.Trim().ToUpperInvariant(),
UnitPrice = unitPrice,
Currency = currency.ToUpperInvariant(),
CreatedAtUtc = DateTime.UtcNow,
CreatedBy = "system"
};
}
public void Update(string name, decimal unitPrice)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Product name is required.", nameof(name));
if (unitPrice < 0)
throw new ArgumentOutOfRangeException(nameof(unitPrice), "Price cannot be negative.");
Name = name.Trim();
UnitPrice = unitPrice;
UpdatedAtUtc = DateTime.UtcNow;
}
public void ChangePrice(decimal newPrice)
{
if (newPrice < 0)
throw new ArgumentOutOfRangeException(nameof(newPrice), "Price cannot be negative.");
UnitPrice = newPrice;
UpdatedAtUtc = DateTime.UtcNow;
}
}
Order.cs
The following is a proper Order entity:
using Ziggy.CRM.Domain.Common;
namespace Ziggy.CRM.Domain.Entities;
public class Order : AuditableEntity
{
private Order() { }
public Guid Id { get; private set; }
public string TenantId { get; private set; } = default!;
public Guid CustomerId { get; private set; }
public Guid ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal TotalPrice { get; private set; }
public Customer Customer { get; private set; } = default!;
public Product Product { get; private set; } = default!;
public static Order Create(string tenantId, Guid customerId, Guid productId, int quantity, decimal totalPrice)
{
if (string.IsNullOrWhiteSpace(tenantId))
throw new ArgumentException("Tenant id is required.", nameof(tenantId));
if (customerId == Guid.Empty)
throw new ArgumentException("Customer id is required.", nameof(customerId));
if (productId == Guid.Empty)
throw new ArgumentException("Product id is required.", nameof(productId));
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be greater than zero.");
if (totalPrice < 0)
throw new ArgumentOutOfRangeException(nameof(totalPrice), "Total price cannot be negative.");
return new Order
{
Id = Guid.NewGuid(),
TenantId = tenantId.Trim(),
CustomerId = customerId,
ProductId = productId,
Quantity = quantity,
TotalPrice = totalPrice,
CreatedAtUtc = DateTime.UtcNow,
CreatedBy = "system"
};
}
public void Update(int quantity, decimal totalPrice)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be greater than zero.");
if (totalPrice < 0)
throw new ArgumentOutOfRangeException(nameof(totalPrice), "Total price cannot be negative.");
Quantity = quantity;
TotalPrice = totalPrice;
}
}
Email.cs
The following is a proper Email ValueObject
namespace Ziggy.CRM.Domain.ValueObjects;
public sealed record Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("A valid email address is required.", nameof(value));
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value;
public static implicit operator string(Email email) => email.Value;
public static explicit operator Email(string value) => new(value);
}
IApplicationDbContext.cs
The following is a proper IApplicationDbContext Contract
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Domain.Contracts;
public interface IApplicationDbContext
{
DbSet<Customer> Customers { get; }
DbSet<Product> Products { get; }
DbSet<Order> Orders { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
ICurrentUserService.cs
The following is a proper ICurrentUserService Contract
namespace Ziggy.CRM.Domain.Contracts;
public interface ICurrentUserService
{
string? UserId { get; }
string? TenantId { get; }
bool IsInRole(string role);
}
IRepository.cs
The following is a proper IRepository Contract
namespace Ziggy.CRM.Domain.Contracts;
public interface IRepository<TEntity> where TEntity : class
{
Task<TEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<List<TEntity>> ListAsync(CancellationToken cancellationToken = default);
Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
void Remove(TEntity entity);
}
ITenantProvider.cs
The following is a proper ITenantProvider Contract
namespace Ziggy.CRM.Domain.Contracts;
public interface ITenantProvider
{
string Current { get; }
}
AppValidationException.cs
The following is a proper AppValidationException Exception
namespace Ziggy.CRM.Domain.Exceptions;
public sealed class AppValidationException : Exception
{
public AppValidationException(string message) : base(message)
{
}
}
DomainException.cs
The following is a proper DomainException Exception
namespace Ziggy.CRM.Domain.Exceptions;
public class DomainException : Exception
{
public DomainException(string message) : base(message)
{
}
public DomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}
EntityNotFoundException.cs
The following is a proper EntityNotFoundException Exception
namespace Ziggy.CRM.Domain.Exceptions;
/// <summary>
/// Exception thrown when an entity is not found.
/// </summary>
public class EntityNotFoundException : DomainException
{
public EntityNotFoundException(string entityName, object id)
: base($"{entityName} with ID '{id}' was not found.")
{
EntityName = entityName;
EntityId = id;
}
public string EntityName { get; }
public object EntityId { get; }
}
ErrorResponse.cs
The following is a proper ErrorResponse Exception
using System.Net;
namespace Ziggy.CRM.Domain.Exceptions;
public sealed class ErrorResponse
{
public HttpStatusCode StatusCode { get; set; }
public string? Message { get; set; }
public string? Details { get; set; }
public string? TraceId { get; set; }
public DateTime Timestamp { get; set; }
}
InvalidEntityStateException.cs
The following is a proper InvalidEntityStateException Exception
namespace Ziggy.CRM.Domain.Exceptions;
/// <summary>
/// Exception thrown when an entity is in an invalid state.
/// </summary>
public class InvalidEntityStateException : DomainException
{
public InvalidEntityStateException(string message)
: base(message)
{
}
public InvalidEntityStateException(string message, Exception innerException)
: base(message, innerException)
{
}
}
AuthTokenResponse.cs
The following is a proper AuthTokenResponse Auth
namespace Ziggy.CRM.Domain.Auth;
public sealed class AuthTokenResponse
{
public string AccessToken { get; set; } = default!;
public string RefreshToken { get; set; } = default!;
public int ExpiresIn { get; set; }
public int RefreshExpiresIn { get; set; }
public string TokenType { get; set; } = "Bearer";
}
KeycloakTokenResponse.cs
The following is a proper KeycloakTokenResponse Auth
using System.Text.Json.Serialization;
namespace Ziggy.CRM.Domain.Auth;
public sealed class KeycloakTokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("refresh_expires_in")]
public int RefreshExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
}
LoginRequest.cs
The following is a proper LoginRequest Auth
namespace Ziggy.CRM.Domain.Auth;
public sealed class LoginRequest
{
public string Username { get; set; } = default!;
public string Password { get; set; } = default!;
}
RefreshTokenRequest.cs
The following is a proper RefreshTokenRequest Auth
namespace Ziggy.CRM.Domain.Auth;
public sealed class RefreshTokenRequest
{
public string RefreshToken { get; set; } = default!;
}
The Ziggy.CRM.Domain Class Library Project is now completed. Now in Step 4 we will be completing the Ziggy.CRM.Infrastructure Class Library Project. Also the best practices are:
There are no public setters
Creation under control
Domain-based validation
State that cannot be changed
Your data is protected in this way.
Step 4: Enforcing Tenant Isolation with EF Core
Tenant filters should never be relied upon by developers.
It is human nature to forget.
It should not be done by systems.
Tenant Provider.cs
using Microsoft.AspNetCore.Http;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Infrastructure.Tenancy;
public sealed class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string Current
{
get
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
throw new UnauthorizedAccessException("No active HTTP context was found.");
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
throw new UnauthorizedAccessException("The current user is not authenticated.");
var tenantId =
user.FindFirst("tenant-id")?.Value ??
user.FindFirst("tenant_id")?.Value ??
user.FindFirst("tenant")?.Value ??
user.FindFirst("tenantid")?.Value;
if (string.IsNullOrWhiteSpace(tenantId))
throw new UnauthorizedAccessException("Tenant claim was not found in the current access token.");
return tenantId;
}
}
}
There is only one source of truth.
CurrentUserService.cs
namespace Ziggy.CRM.Infrastructure.Services;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using Ziggy.CRM.Domain.Contracts;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? UserId =>
_httpContextAccessor.HttpContext?.User?
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
public string? TenantId =>
_httpContextAccessor.HttpContext?.User?
.FindFirst("tenant-id")?.Value;
public bool IsInRole(string role) =>
_httpContextAccessor.HttpContext?.User?
.IsInRole(role) ?? false;
}
ApplicationDbContext.cs (Global Query Filter)
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence.Configurations;
namespace Ziggy.CRM.Infrastructure.Persistence;
public class ApplicationDbContext : DbContext, IApplicationDbContext
{
private readonly ITenantProvider _tenant;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, ITenantProvider tenant)
: base(options)
{
_tenant = tenant;
}
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new CustomerConfiguration());
modelBuilder.ApplyConfiguration(new ProductConfiguration());
modelBuilder.ApplyConfiguration(new OrderConfiguration());
modelBuilder.Entity<Customer>().HasQueryFilter(c => c.TenantId == _tenant.Current);
modelBuilder.Entity<Product>().HasQueryFilter(p => p.TenantId == _tenant.Current);
modelBuilder.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenant.Current);
base.OnModelCreating(modelBuilder);
}
}
Each query is now isolated.
As well as this:
_db.Customers.ToListAsync();
Safe to use.
Step 5: Configuring EF Core Correctly
We are creating 3 configurations, which are as following below
CustomerConfiguration.cs
OrderConfiguration.cs
ProductConfiguration.cs
CustomerConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Configurations;
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.TenantId)
.IsRequired()
.HasMaxLength(64);
builder.Property(c => c.FirstName)
.IsRequired()
.HasMaxLength(100);
builder.Property(c => c.LastName)
.IsRequired()
.HasMaxLength(100);
builder.Property(c => c.Email)
.IsRequired()
.HasMaxLength(256);
builder.HasIndex(c => new { c.TenantId, c.Email })
.IsUnique();
}
}
OrderConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Configurations;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.TenantId).IsRequired().HasMaxLength(64);
builder.Property(o => o.Quantity).IsRequired();
builder.Property(o => o.TotalPrice).IsRequired().HasColumnType("decimal(18,2)");
builder.HasIndex(o => new { o.TenantId, o.CustomerId, o.ProductId }).IsUnique(false);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId);
builder.HasOne(o => o.Product)
.WithMany(p => p.Orders)
.HasForeignKey(o => o.ProductId);
}
}
ProductConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Configurations;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.TenantId).IsRequired().HasMaxLength(64);
builder.Property(p => p.Name).IsRequired().HasMaxLength(150);
builder.Property(p => p.Sku).IsRequired().HasMaxLength(50);
builder.Property(p => p.UnitPrice).IsRequired().HasColumnType("decimal(18,2)");
builder.HasIndex(p => new { p.TenantId, p.Name }).IsUnique();
builder.HasMany(p => p.Orders)
.WithOne(o => o.Product)
.HasForeignKey(o => o.ProductId);
}
}
Now we need to create the 3 Respositories as following and then we create the 4 data seeding.
CustomerRepository.cs
OrderRepository.cs
ProductRepository.cs
CustomerRepository.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.Infrastructure.Repositories;
public sealed class CustomerRepository : IRepository<Customer>
{
private readonly ApplicationDbContext _db;
public CustomerRepository(ApplicationDbContext db) => _db = db;
public Task AddAsync(Customer entity, CancellationToken cancellationToken = default) => _db.Customers.AddAsync(entity, cancellationToken).AsTask();
public Task<Customer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => _db.Customers.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task<List<Customer>> ListAsync(CancellationToken cancellationToken = default) => _db.Customers.ToListAsync(cancellationToken);
public void Remove(Customer entity) => _db.Customers.Remove(entity);
}
OrderRepository.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.Infrastructure.Repositories;
public sealed class OrderRepository : IRepository<Order>
{
private readonly ApplicationDbContext _db;
public OrderRepository(ApplicationDbContext db) => _db = db;
public Task AddAsync(Order entity, CancellationToken cancellationToken = default) => _db.Orders.AddAsync(entity, cancellationToken).AsTask();
public Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => _db.Orders.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task<List<Order>> ListAsync(CancellationToken cancellationToken = default) => _db.Orders.ToListAsync(cancellationToken);
public void Remove(Order entity) => _db.Orders.Remove(entity);
}
ProductRepository.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.Infrastructure.Repositories;
public sealed class ProductRepository : IRepository<Product>
{
private readonly ApplicationDbContext _db;
public ProductRepository(ApplicationDbContext db) => _db = db;
public Task AddAsync(Product entity, CancellationToken cancellationToken = default) => _db.Products.AddAsync(entity, cancellationToken).AsTask();
public Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => _db.Products.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task<List<Product>> ListAsync(CancellationToken cancellationToken = default) => _db.Products.ToListAsync(cancellationToken);
public void Remove(Product entity) => _db.Products.Remove(entity);
}
CustomerSeed.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Seeding;
public static class CustomerSeed
{
public static List<Customer> GetSeedCustomers()
{
return new List<Customer>
{
Customer.Create(TenantSeedData.TenantA, "Alice", "Smith", "[email protected]"),
Customer.Create(TenantSeedData.TenantA, "Bob", "Johnson", "[email protected]"),
Customer.Create(TenantSeedData.TenantB, "Charlie", "Brown", "[email protected]"),
Customer.Create(TenantSeedData.TenantB, "Diana", "Green", "[email protected]"),
};
}
}
OrderSeed.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Seeding;
public static class OrderSeed
{
public static List<Order> GetSeedOrders(List<Customer> customers, List<Product> products)
{
return new List<Order>
{
Order.Create(TenantSeedData.TenantA, customers[0].Id, products[0].Id, 1,products[0].UnitPrice),
Order.Create(TenantSeedData.TenantA, customers[1].Id, products[1].Id, 2, products[1].UnitPrice * 2),
Order.Create(TenantSeedData.TenantB, customers[2].Id, products[2].Id, 1, products[2].UnitPrice),
Order.Create(TenantSeedData.TenantB, customers[3].Id, products[3].Id, 3, products[3].UnitPrice * 3),
};
}
}
ProductSeed.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Infrastructure.Persistence.Seeding;
public static class ProductSeed
{
public static List<Product> GetSeedProducts()
{
return new List<Product>
{
Product.Create(TenantSeedData.TenantA, "Laptop","PRD-001", 11200m),
Product.Create(TenantSeedData.TenantA, "Mouse", "PRD-002", 25m),
Product.Create(TenantSeedData.TenantB, "Monitor", "PRD-003", 300m),
Product.Create(TenantSeedData.TenantB, "Keyboard", "PRD-004 ", 45m),
};
}
}
TenantSeedData.cs
namespace Ziggy.CRM.Infrastructure.Persistence.Seeding;
public static class TenantSeedData
{
public static readonly string TenantA = "tenant_a";
public static readonly string TenantB = "tenant_b";
}
DbContextSeed.cs
To wrap up the data seeding we be creating the DbContextSeed.cs , which will allow us to simple use the 4 data seeding in one method. Allowing use to follow the Dry Principle.
using Ziggy.CRM.Infrastructure.Persistence.Seeding;
namespace Ziggy.CRM.Infrastructure.Persistence;
public static class DbContextSeed
{
public static async Task SeedAsync(ApplicationDbContext context)
{
if (!context.Customers.Any())
{
var customers = CustomerSeed.GetSeedCustomers();
var products = ProductSeed.GetSeedProducts();
var orders = OrderSeed.GetSeedOrders(customers, products);
context.Customers.AddRange(customers);
context.Products.AddRange(products);
context.Orders.AddRange(orders);
await context.SaveChangesAsync();
}
}
}
This is important for the following reasons:
Ensures that duplicates are not created
Enhances performance
Integrity is enforced
Code validation should never be relied upon solely. Next in step 6 we will be creating the Application Layer class library following the CQRS pattern 98% and Service Pattern 2% as for demo purposes.
Step 6: Application Layer with CQRS
Business workflows are separated into Command, Handler, Queries and Dto (Data Transfer Object).
Customers
Commands
CreateCustomerCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Customers.Commands;
public record CreateCustomerCommand(
string FirstName,
string LastName,
string Email
) : IRequest<Guid>;
DeleteCustomerCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Customers.Commands;
public record DeleteCustomerCommand(Guid Id) : IRequest<bool>;
UpdateCustomerCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Customers.Commands;
public record UpdateCustomerCommand(Guid Id, string FirstName, string LastName, string Email) : IRequest<bool>;
Handlers
CreateCustomerCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Domain.Exceptions;
namespace Ziggy.CRM.Application.Customers.Handlers;
public sealed class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, Guid>
{
private readonly IApplicationDbContext _db;
private readonly ITenantProvider _tenant;
public CreateCustomerCommandHandler(IApplicationDbContext db, ITenantProvider tenant)
{
_db = db;
_tenant = tenant;
}
public async Task<Guid> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
var exists = await _db.Customers
.AnyAsync(x => x.Email == request.Email.ToLower(), cancellationToken);
if (exists)
{
throw new AppValidationException(
"A customer with the same email already exists for this tenant.");
}
var customer = Customer.Create(
_tenant.Current,
request.FirstName,
request.LastName,
request.Email);
_db.Customers.Add(customer);
await _db.SaveChangesAsync(cancellationToken);
return customer.Id;
}
}
DeleteCustomerCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Customers.Handlers;
public sealed class DeleteCustomerCommandHandler : IRequestHandler<DeleteCustomerCommand, bool>
{
private readonly IApplicationDbContext _db;
public DeleteCustomerCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(DeleteCustomerCommand request, CancellationToken cancellationToken)
{
var customer = await _db.Customers
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (customer is null)
{
return false;
}
customer.Deactivate();
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
GetCustomerByIdQueryHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Dtos;
using Ziggy.CRM.Application.Customers.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Customers.Handlers;
public sealed class GetCustomerByIdQueryHandler : IRequestHandler<GetCustomerByIdQuery, CustomerDto?>
{
private readonly IApplicationDbContext _db;
public GetCustomerByIdQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<CustomerDto?> Handle(
GetCustomerByIdQuery request,
CancellationToken cancellationToken)
{
return await _db.Customers
.AsNoTracking()
.Where(x => x.Id == request.Id)
.Select(x => new CustomerDto(
x.Id,
x.FirstName,
x.LastName,
x.Email,
x.IsActive))
.FirstOrDefaultAsync(cancellationToken);
}
}
GetCustomersQueryHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Dtos;
using Ziggy.CRM.Application.Customers.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Customers.Handlers;
public sealed class GetCustomersQueryHandler : IRequestHandler<GetCustomersQuery, List<CustomerDto>>
{
private readonly IApplicationDbContext _db;
public GetCustomersQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<List<CustomerDto>> Handle(
GetCustomersQuery request,
CancellationToken cancellationToken)
{
return await _db.Customers
.AsNoTracking()
.OrderBy(x => x.FirstName)
.ThenBy(x => x.LastName)
.Select(x => new CustomerDto(
x.Id,
x.FirstName,
x.LastName,
x.Email,
x.IsActive))
.ToListAsync(cancellationToken);
}
}
UpdateCustomerCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Exceptions;
namespace Ziggy.CRM.Application.Customers.Handlers;
public sealed class UpdateCustomerCommandHandler : IRequestHandler<UpdateCustomerCommand, bool>
{
private readonly IApplicationDbContext _db;
public UpdateCustomerCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(UpdateCustomerCommand request, CancellationToken cancellationToken)
{
var customer = await _db.Customers
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (customer is null)
{
return false;
}
customer.Update(request.FirstName, request.LastName, request.Email);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
Queries
GetCustomerByIdQuery.cs
using MediatR;
using Ziggy.CRM.Application.Customers.Dtos;
namespace Ziggy.CRM.Application.Customers.Queries;
public record GetCustomerByIdQuery(Guid Id) : IRequest<CustomerDto?>;
GetCustomersQuery.cs
using MediatR;
using Ziggy.CRM.Application.Customers.Dtos;
namespace Ziggy.CRM.Application.Customers.Queries;
public record GetCustomersQuery : IRequest<List<CustomerDto>>;
Dtos
CustomerDto.cs
namespace Ziggy.CRM.Application.Customers.Dtos;
/// <summary>
/// Data transfer object for customer responses.
/// </summary>
public record CustomerDto(
Guid Id,
string FirstName,
string LastName,
string Email,
bool IsActive);
Orders
Commands
CreateOrderCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Orders.Commands;
public record CreateOrderCommand(Guid CustomerId, Guid ProductId, int Quantity) : IRequest<Guid>;
DeleteOrderCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Orders.Commands;
public record DeleteOrderCommand(Guid Id) : IRequest<bool>;
UpdateOrderCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Orders.Commands;
public record UpdateOrderCommand(Guid Id, int Quantity) : IRequest<bool>;
Handlers
CreateOrderCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Orders.Commands;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Domain.Exceptions;
namespace Ziggy.CRM.Application.Orders.Handlers;
public sealed class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IApplicationDbContext _db;
private readonly ITenantProvider _tenant;
public CreateOrderCommandHandler(IApplicationDbContext db, ITenantProvider tenant)
{
_db = db;
_tenant = tenant;
}
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var product = await _db.Products
.FirstOrDefaultAsync(x => x.Id == request.ProductId, cancellationToken);
if (product is null)
{
throw new DomainException("Product not found.");
}
var customerExists = await _db.Customers
.AnyAsync(x => x.Id == request.CustomerId, cancellationToken);
if (!customerExists)
{
throw new DomainException("Customer not found.");
}
var total = product.UnitPrice * request.Quantity;
var order = Order.Create(
_tenant.Current,
request.CustomerId,
request.ProductId,
request.Quantity,
total);
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
DeleteOrderCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Orders.Commands;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Orders.Handlers;
public sealed class DeleteOrderCommandHandler : IRequestHandler<DeleteOrderCommand, bool>
{
private readonly IApplicationDbContext _db;
public DeleteOrderCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(DeleteOrderCommand request, CancellationToken cancellationToken)
{
var order = await _db.Orders
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (order is null)
{
return false;
}
_db.Orders.Remove(order);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
GetOrderByIdQueryHandler.cs.
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Orders.Dtos;
using Ziggy.CRM.Application.Orders.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Orders.Handlers;
public sealed class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IApplicationDbContext _db;
public GetOrderByIdQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
return await _db.Orders
.AsNoTracking()
.Where(x => x.Id == request.Id)
.Select(x => new OrderDto(
x.Id,
x.CustomerId,
x.ProductId,
x.Quantity,
x.TotalPrice))
.FirstOrDefaultAsync(cancellationToken);
}
}
GetOrdersQueryHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Orders.Dtos;
using Ziggy.CRM.Application.Orders.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Orders.Handlers;
public sealed class GetOrdersQueryHandler : IRequestHandler<GetOrdersQuery, List<OrderDto>>
{
private readonly IApplicationDbContext _db;
public GetOrdersQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<List<OrderDto>> Handle(
GetOrdersQuery request,
CancellationToken cancellationToken)
{
return await _db.Orders
.AsNoTracking()
.OrderByDescending(x => x.Id)
.Select(x => new OrderDto(
x.Id,
x.CustomerId,
x.ProductId,
x.Quantity,
x.TotalPrice))
.ToListAsync(cancellationToken);
}
}
UpdateOrderCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Orders.Commands;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Orders.Handlers;
public sealed class UpdateOrderCommandHandler : IRequestHandler<UpdateOrderCommand, bool>
{
private readonly IApplicationDbContext _db;
public UpdateOrderCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(UpdateOrderCommand request, CancellationToken cancellationToken)
{
var order = await _db.Orders
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (order is null)
{
return false;
}
var productPrice = await _db.Products
.Where(x => x.Id == order.ProductId)
.Select(x => x.UnitPrice)
.FirstAsync(cancellationToken);
order.Update(request.Quantity, productPrice * request.Quantity);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
Queries
GetOrderByIdQuery.cs
using MediatR;
using Ziggy.CRM.Application.Orders.Dtos;
namespace Ziggy.CRM.Application.Orders.Queries;
public record GetOrderByIdQuery(Guid Id) : IRequest<OrderDto?>;
GetOrdersQuery.cs
using MediatR;
using Ziggy.CRM.Application.Orders.Dtos;
namespace Ziggy.CRM.Application.Orders.Queries;
public record GetOrdersQuery : IRequest<List<OrderDto>>;
Dtos
OrderDto.cs
namespace Ziggy.CRM.Application.Orders.Dtos;
/// <summary>
/// Data transfer object for order responses.
/// </summary>
public record OrderDto(
Guid Id,
Guid CustomerId,
Guid ProductId,
int Quantity,
decimal TotalPrice);
Products
Commands
CreateProductCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Products.Commands;
public sealed record CreateProductCommand : IRequest<Guid>
{
public string Name { get; init; } = string.Empty;
public string Sku { get; init; } = default!;
public decimal UnitPrice { get; init; }
}DeleteProductCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Products.Commands;
public record DeleteProductCommand(Guid Id) : IRequest<bool>;
UpdateProductCommand.cs
using MediatR;
namespace Ziggy.CRM.Application.Products.Commands;
public sealed record UpdateProductCommand : IRequest<bool>
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public decimal Price { get; init; }
}
Handlers
CreateProductCommandHandler.cs

Join the conversation! Your thoughts help the community grow.