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
Open PowerShell after installation and check:
docker --versionDocker 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-devThe 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 psA 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 keycloakTo remove it:
docker rm -f keycloakYou 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
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Products.Commands;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.Application.Products.Handlers;
public sealed class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
{
private readonly IApplicationDbContext _db;
private readonly ITenantProvider _tenant;
public CreateProductCommandHandler(IApplicationDbContext db, ITenantProvider tenant)
{
_db = db;
_tenant = tenant;
}
public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var entity = Product.Create(
_tenant.Current,
request.Name,
request.Sku,
request.UnitPrice);
_db.Products.Add(entity);
await _db.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}DeleteProductCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Products.Commands;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Products.Handlers;
public sealed class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, bool>
{
private readonly IApplicationDbContext _db;
public DeleteProductCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
{
var entity = await _db.Products
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (entity is null)
{
return false;
}
_db.Products.Remove(entity);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}GetProductByIdQueryHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Products.Dtos;
using Ziggy.CRM.Application.Products.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Products.Handlers;
public sealed class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly IApplicationDbContext _db;
public GetProductByIdQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<ProductDto?> Handle(
GetProductByIdQuery request,
CancellationToken cancellationToken)
{
return await _db.Products
.AsNoTracking()
.Where(x => x.Id == request.Id)
.Select(x => new ProductDto(
x.Id,
x.Name,
x.UnitPrice))
.FirstOrDefaultAsync(cancellationToken);
}
}GetProductsQueryHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Products.Dtos;
using Ziggy.CRM.Application.Products.Queries;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Products.Handlers;
public sealed class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, List<ProductDto>>
{
private readonly IApplicationDbContext _db;
public GetProductsQueryHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<List<ProductDto>> Handle(
GetProductsQuery request,
CancellationToken cancellationToken)
{
return await _db.Products
.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new ProductDto(
x.Id,
x.Name,
x.UnitPrice))
.ToListAsync(cancellationToken);
}
}UpdateProductCommandHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Products.Commands;
using Ziggy.CRM.Domain.Contracts;
namespace Ziggy.CRM.Application.Products.Handlers;
public sealed class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, bool>
{
private readonly IApplicationDbContext _db;
public UpdateProductCommandHandler(IApplicationDbContext db)
{
_db = db;
}
public async Task<bool> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
{
var entity = await _db.Products
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (entity is null)
{
return false;
}
entity.Update(request.Name, request.Price);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}Queries
GetProductByIdQuery.cs
using MediatR;
using Ziggy.CRM.Application.Products.Dtos;
namespace Ziggy.CRM.Application.Products.Queries;
public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto?>;GetProductsQuery.cs
using MediatR;
using Ziggy.CRM.Application.Products.Dtos;
namespace Ziggy.CRM.Application.Products.Queries;
public record GetProductsQuery : IRequest<List<ProductDto>>;Dtos
ProductDto.cs
namespace Ziggy.CRM.Application.Products.Dtos;
/// <summary>
/// Data transfer object for product responses.
/// </summary>
public record ProductDto(
Guid Id,
string Name,
decimal UnitPrice);What are the benefits of CQRS?
Testing is easier
Intent that is clear
It scales better
Microservices work well with this framework
Service Pattern
Now we are following the Service Patterrn as following
IKeycloakAuthService.cs
using Ziggy.CRM.Domain.Auth;
namespace Ziggy.CRM.Application.Services.Auth;
public interface IKeycloakAuthService
{
Task<AuthTokenResponse> LoginAsync(
string username,
string password,
CancellationToken cancellationToken);
Task<AuthTokenResponse> RefreshTokenAsync(
string refreshToken,
CancellationToken cancellationToken);
}KeycloakAuthService.cs
using Microsoft.Extensions.Configuration;
using System.Net.Http.Headers;
using System.Text.Json;
using Ziggy.CRM.Domain.Auth;
namespace Ziggy.CRM.Application.Services.Auth;
public sealed class KeycloakAuthService : IKeycloakAuthService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
public KeycloakAuthService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
}
public Task<AuthTokenResponse> LoginAsync(
string username,
string password,
CancellationToken cancellationToken)
{
var form = new Dictionary<string, string>
{
["client_id"] = GetRequired("Keycloak:ClientId"),
["client_secret"] = GetRequired("Keycloak:ClientSecret"),
["username"] = username,
["password"] = password,
["grant_type"] = "password",
["scope"] = "openid profile email"
};
return SendTokenRequestAsync(form, cancellationToken);
}
public Task<AuthTokenResponse> RefreshTokenAsync(
string refreshToken,
CancellationToken cancellationToken)
{
var form = new Dictionary<string, string>
{
["client_id"] = GetRequired("Keycloak:ClientId"),
["client_secret"] = GetRequired("Keycloak:ClientSecret"),
["refresh_token"] = refreshToken,
["grant_type"] = "refresh_token"
};
return SendTokenRequestAsync(form, cancellationToken);
}
private async Task<AuthTokenResponse> SendTokenRequestAsync(
Dictionary<string, string> form,
CancellationToken cancellationToken)
{
var authority = GetRequired("Keycloak:Authority");
var tokenUrl = $"{authority}/protocol/openid-connect/token";
using var client = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
{
Content = new FormUrlEncodedContent(form)
};
request.Content.Headers.ContentType =
new MediaTypeHeaderValue("application/x-www-form-urlencoded");
using var response = await client.SendAsync(request, cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new UnauthorizedAccessException(json);
}
var token = JsonSerializer.Deserialize<KeycloakTokenResponse>(
json,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (token?.AccessToken is null)
{
throw new InvalidOperationException("Keycloak returned an invalid token response.");
}
return new AuthTokenResponse
{
AccessToken = token.AccessToken,
RefreshToken = token.RefreshToken ?? string.Empty,
ExpiresIn = token.ExpiresIn,
RefreshExpiresIn = token.RefreshExpiresIn,
TokenType = token.TokenType ?? "Bearer"
};
}
private string GetRequired(string key)
{
return _configuration[key]
?? throw new InvalidOperationException($"Missing configuration value: {key}");
}
}Step 7: Securing Endpoints Properly
Security
KeycloakRoleClaimsTransformer.cs
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Text.Json;
namespace Ziggy.CRM.Api.Security;
public sealed class KeycloakRoleClaimsTransformer : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity)
{
return Task.FromResult(principal);
}
AddRealmRoles(identity);
AddClientRoles(identity);
return Task.FromResult(principal);
}
private static void AddRealmRoles(ClaimsIdentity identity)
{
var realmAccess = identity.FindFirst("realm_access")?.Value;
if (string.IsNullOrWhiteSpace(realmAccess))
{
return;
}
using var json = JsonDocument.Parse(realmAccess);
if (!json.RootElement.TryGetProperty("roles", out var roles))
{
return;
}
foreach (var role in roles.EnumerateArray())
{
var roleValue = role.GetString();
if (!string.IsNullOrWhiteSpace(roleValue))
{
identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
identity.AddClaim(new Claim("role", roleValue));
}
}
}
private static void AddClientRoles(ClaimsIdentity identity)
{
var resourceAccess = identity.FindFirst("resource_access")?.Value;
if (string.IsNullOrWhiteSpace(resourceAccess))
{
return;
}
using var json = JsonDocument.Parse(resourceAccess);
foreach (var client in json.RootElement.EnumerateObject())
{
if (!client.Value.TryGetProperty("roles", out var roles))
{
continue;
}
foreach (var role in roles.EnumerateArray())
{
var roleValue = role.GetString();
if (!string.IsNullOrWhiteSpace(roleValue))
{
identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
identity.AddClaim(new Claim("role", roleValue));
}
}
}
}
}Middleware
ExceptionHandlingMiddleware.cs
using System.Diagnostics;
using System.Net;
using System.Text.Json;
using Ziggy.CRM.Domain.Exceptions;
namespace Ziggy.CRM.Api.Middleware;
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception exception)
{
_logger.LogError(exception, "An unhandled exception occurred while processing the request.");
await HandleExceptionAsync(context, exception);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var response = new ErrorResponse
{
TraceId = Activity.Current?.Id ?? context.TraceIdentifier,
Timestamp = DateTime.UtcNow
};
switch (exception)
{
case AppValidationException validationException:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
response.StatusCode = HttpStatusCode.BadRequest;
response.Message = validationException.Message;
response.Details = "A validation error occurred.";
break;
case DomainException domainException:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
response.StatusCode = HttpStatusCode.BadRequest;
response.Message = domainException.Message;
response.Details = "A domain error occurred.";
break;
case UnauthorizedAccessException:
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
response.StatusCode = HttpStatusCode.Unauthorized;
response.Message = "You are not authorized to access this resource.";
response.Details = "Authentication is required.";
break;
case KeyNotFoundException keyNotFoundException:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
response.StatusCode = HttpStatusCode.NotFound;
response.Message = keyNotFoundException.Message;
response.Details = "The requested resource was not found.";
break;
case OperationCanceledException when exception.InnerException is TimeoutException:
context.Response.StatusCode = (int)HttpStatusCode.RequestTimeout;
response.StatusCode = HttpStatusCode.RequestTimeout;
response.Message = "The request took too long to process.";
response.Details = "The operation timed out.";
break;
case ArgumentException argumentException:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
response.StatusCode = HttpStatusCode.BadRequest;
response.Message = argumentException.Message;
response.Details = "An invalid argument was provided.";
break;
default:
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
response.StatusCode = HttpStatusCode.InternalServerError;
response.Message = "An unexpected error occurred while processing your request.";
response.Details = "Please contact support if the problem persists.";
break;
}
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
return context.Response.WriteAsJsonAsync(response, jsonOptions);
}
}Extensions
ExceptionHandlingExtensions.cs
using Ziggy.CRM.Api.Middleware;
namespace Ziggy.CRM.Api.Extensions;
public static class ExceptionHandlingExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder app)
{
return app.UseMiddleware<ExceptionHandlingMiddleware>();
}
}Controllers
DebugController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Ziggy.CRM.Api.Controllers;
[ApiController]
[Route("api/debug")]
public class DebugController : ControllerBase
{
[HttpGet("me")]
[Authorize]
public IActionResult Me()
{
return Ok(new
{
IsAuthenticated = User.Identity?.IsAuthenticated,
Name = User.Identity?.Name,
Claims = User.Claims.Select(c => new
{
c.Type,
c.Value
})
});
}
}AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Ziggy.CRM.Application.Services.Auth;
using Ziggy.CRM.Domain.Auth;
namespace Ziggy.CRM.Api.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IKeycloakAuthService _keycloakAuthService;
public AuthController(IKeycloakAuthService keycloakAuthService)
{
_keycloakAuthService = keycloakAuthService;
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<IActionResult> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken)
{
var token = await _keycloakAuthService.LoginAsync(
request.Username,
request.Password,
cancellationToken);
return Ok(token);
}
[AllowAnonymous]
[HttpPost("admin-token")]
public async Task<IActionResult> GenerateAdminToken(CancellationToken cancellationToken)
{
var token = await _keycloakAuthService.LoginAsync(
"admin-companya",
"Admin123!",
cancellationToken);
return Ok(token);
}
[AllowAnonymous]
[HttpPost("manager-token")]
public async Task<IActionResult> GenerateFfManagerToken(CancellationToken cancellationToken)
{
var token = await _keycloakAuthService.LoginAsync(
"manager-companya",
"Manager123!",
cancellationToken);
return Ok(token);
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(
[FromBody] RefreshTokenRequest request,
CancellationToken cancellationToken)
{
var token = await _keycloakAuthService.RefreshTokenAsync(
request.RefreshToken,
cancellationToken);
return Ok(token);
}
[Authorize]
[HttpGet("me")]
public IActionResult Me()
{
return Ok(new
{
username = User.Identity?.Name,
authenticated = User.Identity?.IsAuthenticated,
roles = User.Claims
.Where(x => x.Type == "role" || x.Type.EndsWith("/role"))
.Select(x => x.Value)
.Distinct()
.ToArray(),
claims = User.Claims.Select(x => new
{
x.Type,
x.Value
})
});
}
[Authorize(Policy = "AdminOnly")]
[HttpGet("admin-only")]
public IActionResult AdminOnly()
{
return Ok("Admin access granted.");
}
[Authorize(Policy = "ManagerOrAdmin")]
[HttpGet("manager-or-admin")]
public IActionResult ManagerOrAdmin()
{
return Ok("Manager or admin access granted.");
}
}OrdersController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Ziggy.CRM.Application.Orders.Commands;
using Ziggy.CRM.Application.Orders.Queries;
namespace Ziggy.CRM.Api.Controllers;
[ApiController]
[Route("api/orders")]
[Authorize]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
[Authorize(Policy = "UserAccess")]
public async Task<IActionResult> GetOrders(CancellationToken cancellationToken)
=> Ok(await _mediator.Send(new GetOrdersQuery(), cancellationToken));
[HttpGet("{id:guid}")]
[Authorize(Policy = "UserAccess")]
public async Task<IActionResult> GetById(Guid id, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetOrderByIdQuery(id), cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Roles = "admin,manager")]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken)
{
var id = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admin,manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOrderCommand command, CancellationToken cancellationToken)
{
var updated = await _mediator.Send(command with { Id = id }, cancellationToken);
return updated ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "AdminOnly")]
public async Task<IActionResult> DeleteOrder(Guid id, CancellationToken cancellationToken)
{
var deleted = await _mediator.Send(new DeleteOrderCommand(id), cancellationToken);
return deleted ? NoContent() : NotFound();
}
}ProductsController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Ziggy.CRM.Application.Products.Commands;
using Ziggy.CRM.Application.Products.Queries;
namespace Ziggy.CRM.Api.Controllers;
[ApiController]
[Route("api/products")]
[Authorize(Policy = "UserAccess")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
public async Task<IActionResult> GetProducts(CancellationToken cancellationToken)
=> Ok(await _mediator.Send(new GetProductsQuery(), cancellationToken));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetProductByIdQuery(id), cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Roles = "admin,manager")]
public async Task<IActionResult> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken)
{
var id = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admin,manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProductCommand command, CancellationToken cancellationToken)
{
var updated = await _mediator.Send(command with { Id = id }, cancellationToken);
return updated ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "AdminOnly")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var deleted = await _mediator.Send(new DeleteProductCommand(id), cancellationToken);
return deleted ? NoContent() : NotFound();
}
}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();appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Server=localhost;Database=ZiggyCrmDemo;User Id=***;Password=*********;TrustServerCertificate=True;"
},
"Keycloak": {
"Authority": "http://localhost:9090/realms/ziggy",
"ClientId": "ziggy-api",
"ClientSecret": "****************************"
},
"Jwt": {
"Authority": "http://localhost:9090/realms/ziggy"
},
"AllowedHosts": "*"
}Ziggy.CRM.Api.http
@Ziggy.CRM.Api_HostAddress = http://localhost:5197
### 1. Get Token from Keycloak
POST http://localhost:9090/realms/ziggy/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
client_id=ziggy-api&
client_secret=YOUR_CLIENT_SECRET&
username=admin-companya&
password=Admin123!&
grant_type=password&
scope=openid profile email
###
# Copy access_token from above response and paste below
@access_token = REPLACE_WITH_ACCESS_TOKEN
###
### 2. Create Customer
POST {{Ziggy.CRM.Api_HostAddress}}/api/customers
Authorization: Bearer {{access_token}}
Content-Type: application/json
{
"firstName": "Aisha",
"lastName": "Rahman",
"email": "[email protected]"
}
###
### 3. Get Customer by Id
GET {{Ziggy.CRM.Api_HostAddress}}/api/customers/{{customer_id}}
Authorization: Bearer {{access_token}}
###
### 4. Get All Customers (Tenant Scoped)
GET {{Ziggy.CRM.Api_HostAddress}}/api/customers
Authorization: Bearer {{access_token}}
###
### 5. Update Customer
PUT {{Ziggy.CRM.Api_HostAddress}}/api/customers/{{customer_id}}
Authorization: Bearer {{access_token}}
Content-Type: application/json
{
"firstName": "Aisha Updated",
"lastName": "Rahman",
"email": "[email protected]"
}
###
### 6. Delete Customer
DELETE {{Ziggy.CRM.Api_HostAddress}}/api/customers/{{customer_id}}
Authorization: Bearer {{access_token}}
###
### 7. Create Product
POST {{Ziggy.CRM.Api_HostAddress}}/api/products
Authorization: Bearer {{access_token}}
Content-Type: application/json
{
"sku": "PRD-1001",
"name": "Laptop Stand Pro",
"price": 49.99
}
###
### 8. Create Order
POST {{Ziggy.CRM.Api_HostAddress}}/api/orders
Authorization: Bearer {{access_token}}
Content-Type: application/json
{
"orderNumber": "ORD-2026-0001",
"status": "Pending"
}The best practices are:
Role-based policies should be used
Manual checks should be avoided
Rules should be centralized
Step 8: Auditing for Compliance
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; }
}You get the following benefits with Keycloak IDs:
Traceability under GDPR
Evidence from SOC2
Investigation of an incident
It's almost free.
Step 9: Testing Security
Untested security should never be trusted.
Unit Test
ApplicationHandlerTests.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Application.Customers.Handlers;
using Ziggy.CRM.Application.Customers.Queries;
using Ziggy.CRM.Application.Orders.Commands;
using Ziggy.CRM.Application.Orders.Handlers;
using Ziggy.CRM.Application.Orders.Queries;
using Ziggy.CRM.Application.Products.Commands;
using Ziggy.CRM.Application.Products.Handlers;
using Ziggy.CRM.Application.Products.Queries;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Domain.Exceptions;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.UnitTests.Application;
public sealed class ApplicationHandlerTests
{
[Fact]
public async Task CustomerHandlers_CreateReadUpdateDelete_WorkAsExpected()
{
await using var db = CreateDbContext("tenant-a");
var tenant = new TestTenantProvider("tenant-a");
var create = new CreateCustomerCommandHandler(db, tenant);
var getAll = new GetCustomersQueryHandler(db);
var getById = new GetCustomerByIdQueryHandler(db);
var update = new UpdateCustomerCommandHandler(db);
var delete = new DeleteCustomerCommandHandler(db);
var id = await create.Handle(new CreateCustomerCommand("Ziggy", "Rafiq", "[email protected]"), CancellationToken.None);
var all = await getAll.Handle(new GetCustomersQuery(), CancellationToken.None);
var found = await getById.Handle(new GetCustomerByIdQuery(id), CancellationToken.None);
var updated = await update.Handle(new UpdateCustomerCommand(id, "Ayoub", "Rafiq", "[email protected]"), CancellationToken.None);
var deleted = await delete.Handle(new DeleteCustomerCommand(id), CancellationToken.None);
var missingUpdate = await update.Handle(new UpdateCustomerCommand(Guid.NewGuid(), "A", "B", "[email protected]"), CancellationToken.None);
var missingDelete = await delete.Handle(new DeleteCustomerCommand(Guid.NewGuid()), CancellationToken.None);
Assert.Single(all);
Assert.NotNull(found);
Assert.Equal("Ziggy", found!.FirstName);
Assert.True(updated);
Assert.True(deleted);
Assert.False(missingUpdate);
Assert.False(missingDelete);
Assert.False((await db.Customers.FindAsync([id], CancellationToken.None))!.IsActive);
}
[Fact]
public async Task CreateCustomer_WhenDuplicateEmail_ThrowsAppValidationException()
{
await using var db = CreateDbContext("tenant-a");
var handler = new CreateCustomerCommandHandler(db, new TestTenantProvider("tenant-a"));
await handler.Handle(new CreateCustomerCommand("Ziggy", "Rafiq", "[email protected]"), CancellationToken.None);
await Assert.ThrowsAsync<AppValidationException>(() => handler.Handle(new CreateCustomerCommand("Other", "User", "[email protected]"), CancellationToken.None));
}
[Fact]
public async Task ProductHandlers_CreateReadUpdateDelete_WorkAsExpected()
{
await using var db = CreateDbContext("tenant-a");
var tenant = new TestTenantProvider("tenant-a");
var create = new CreateProductCommandHandler(db, tenant);
var getAll = new GetProductsQueryHandler(db);
var getById = new GetProductByIdQueryHandler(db);
var update = new UpdateProductCommandHandler(db);
var delete = new DeleteProductCommandHandler(db);
var id = await create.Handle(new CreateProductCommand { Name = "Laptop", Sku = "prd-1", UnitPrice = 100m }, CancellationToken.None);
var all = await getAll.Handle(new GetProductsQuery(), CancellationToken.None);
var found = await getById.Handle(new GetProductByIdQuery(id), CancellationToken.None);
var updated = await update.Handle(new UpdateProductCommand { Id = id, Name = "Laptop Pro", Price = 150m }, CancellationToken.None);
var deleted = await delete.Handle(new DeleteProductCommand(id), CancellationToken.None);
var missingUpdate = await update.Handle(new UpdateProductCommand { Id = Guid.NewGuid(), Name = "Missing", Price = 1m }, CancellationToken.None);
var missingDelete = await delete.Handle(new DeleteProductCommand(Guid.NewGuid()), CancellationToken.None);
Assert.Single(all);
Assert.Equal("Laptop", found!.Name);
Assert.True(updated);
Assert.True(deleted);
Assert.False(missingUpdate);
Assert.False(missingDelete);
}
[Fact]
public async Task OrderHandlers_CreateReadUpdateDelete_WorkAsExpected()
{
await using var db = CreateDbContext("tenant-a");
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
var product = Product.Create("tenant-a", "Laptop", "prd-1", 100m);
db.Customers.Add(customer);
db.Products.Add(product);
await db.SaveChangesAsync();
var tenant = new TestTenantProvider("tenant-a");
var create = new CreateOrderCommandHandler(db, tenant);
var getAll = new GetOrdersQueryHandler(db);
var getById = new GetOrderByIdQueryHandler(db);
var update = new UpdateOrderCommandHandler(db);
var delete = new DeleteOrderCommandHandler(db);
var id = await create.Handle(
new CreateOrderCommand(customer.Id, product.Id, 2),
CancellationToken.None);
var createdOrder = await db.Orders
.IgnoreQueryFilters()
.SingleOrDefaultAsync(x => x.Id == id);
Assert.NotNull(createdOrder);
Assert.Equal("tenant-a", createdOrder!.TenantId);
Assert.Equal(200m, createdOrder.TotalPrice);
var all = await getAll.Handle(new GetOrdersQuery(), CancellationToken.None);
Assert.Single(all);
var found = await getById.Handle(new GetOrderByIdQuery(id), CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(200m, found!.TotalPrice);
var updated = await update.Handle(
new UpdateOrderCommand(id, 3),
CancellationToken.None);
Assert.True(updated);
var updatedOrder = await db.Orders
.IgnoreQueryFilters()
.SingleOrDefaultAsync(x => x.Id == id);
Assert.NotNull(updatedOrder);
Assert.Equal(300m, updatedOrder!.TotalPrice);
var deleted = await delete.Handle(
new DeleteOrderCommand(id),
CancellationToken.None);
Assert.True(deleted);
var deletedOrder = await db.Orders
.IgnoreQueryFilters()
.SingleOrDefaultAsync(x => x.Id == id);
Assert.Null(deletedOrder);
var missingUpdate = await update.Handle(
new UpdateOrderCommand(Guid.NewGuid(), 1),
CancellationToken.None);
var missingDelete = await delete.Handle(
new DeleteOrderCommand(Guid.NewGuid()),
CancellationToken.None);
Assert.False(missingUpdate);
Assert.False(missingDelete);
}
[Fact]
public async Task CreateOrder_WhenProductOrCustomerMissing_ThrowsDomainException()
{
await using var db = CreateDbContext("tenant-a");
var handler = new CreateOrderCommandHandler(db, new TestTenantProvider("tenant-a"));
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
db.Customers.Add(customer);
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(() => handler.Handle(new CreateOrderCommand(customer.Id, Guid.NewGuid(), 1), CancellationToken.None));
var product = Product.Create("tenant-a", "Laptop", "prd-1", 100m);
db.Products.Add(product);
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(() => handler.Handle(new CreateOrderCommand(Guid.NewGuid(), product.Id, 1), CancellationToken.None));
}
private static ApplicationDbContext CreateDbContext(string tenant)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options, new TestTenantProvider(tenant));
}
private sealed class TestTenantProvider(string tenant) : ITenantProvider
{
public string Current { get; } = tenant;
}
}CustomerTests.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.UnitTests.Domain;
public sealed class CustomerTests
{
[Fact]
public void Create_WhenValidInput_ReturnsActiveCustomerWithNormalisedEmail()
{
var customer = Customer.Create(" tenant-a ", " Ziggy ", " Rafiq ", " [email protected] ");
Assert.NotEqual(Guid.Empty, customer.Id);
Assert.Equal("tenant-a", customer.TenantId);
Assert.Equal("Ziggy", customer.FirstName);
Assert.Equal("Rafiq", customer.LastName);
Assert.Equal("[email protected]", customer.Email);
Assert.True(customer.IsActive);
Assert.Equal("system", customer.CreatedBy);
Assert.True(customer.CreatedAtUtc <= DateTime.UtcNow);
}
[Theory]
[InlineData("", "Ziggy", "Rafiq", "[email protected]", "tenantId")]
[InlineData("tenant-a", "", "Rafiq", "[email protected]", "firstName")]
[InlineData("tenant-a", "Ziggy", "", "[email protected]", "lastName")]
[InlineData("tenant-a", "Ziggy", "Rafiq", "", "email")]
public void Create_WhenRequiredInputMissing_ThrowsArgumentException(string tenant, string first, string last, string email, string parameterName)
{
var exception = Assert.Throws<ArgumentException>(() => Customer.Create(tenant, first, last, email));
Assert.Equal(parameterName, exception.ParamName);
}
[Fact]
public void Update_WhenValidInput_UpdatesNamesAndNormalisesEmail()
{
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
customer.Update(" Ayoub ", " Rafiq ", " [email protected] ");
Assert.Equal("Ayoub", customer.FirstName);
Assert.Equal("Rafiq", customer.LastName);
Assert.Equal("[email protected]", customer.Email);
}
[Theory]
[InlineData("", "Rafiq", "[email protected]", "firstName")]
[InlineData("Ziggy", "", "[email protected]", "lastName")]
[InlineData("Ziggy", "Rafiq", "", "email")]
public void Update_WhenRequiredInputMissing_ThrowsArgumentException(string first, string last, string email, string parameterName)
{
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
var exception = Assert.Throws<ArgumentException>(() => customer.Update(first, last, email));
Assert.Equal(parameterName, exception.ParamName);
}
[Fact]
public void Deactivate_SetsIsActiveToFalse()
{
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
customer.Deactivate();
Assert.False(customer.IsActive);
}
}EmailAndExceptionTests.cs
using Ziggy.CRM.Domain.Exceptions;
using Ziggy.CRM.Domain.ValueObjects;
namespace Ziggy.CRM.UnitTests.Domain;
public sealed class EmailAndExceptionTests
{
[Fact]
public void Email_WhenValid_NormalisesValueAndSupportsConversions()
{
var email = new Email(" [email protected] ");
string asString = email;
var explicitEmail = (Email)"[email protected]";
Assert.Equal("[email protected]", email.Value);
Assert.Equal("[email protected]", email.ToString());
Assert.Equal("[email protected]", asString);
Assert.Equal("[email protected]", explicitEmail.Value);
}
[Theory]
[InlineData("")]
[InlineData("not-an-email")]
public void Email_WhenInvalid_ThrowsArgumentException(string value)
{
var exception = Assert.Throws<ArgumentException>(() => new Email(value));
Assert.Equal("value", exception.ParamName);
}
[Fact]
public void EntityNotFoundException_ContainsEntityNameAndId()
{
var id = Guid.NewGuid();
var exception = new EntityNotFoundException("Customer", id);
Assert.Equal("Customer", exception.EntityName);
Assert.Equal(id, exception.EntityId);
Assert.Contains(id.ToString(), exception.Message);
}
[Fact]
public void DomainException_WithInnerException_PreservesInnerException()
{
var inner = new InvalidOperationException("inner");
var exception = new DomainException("domain", inner);
Assert.Equal("domain", exception.Message);
Assert.Same(inner, exception.InnerException);
}
[Fact]
public void InvalidEntityStateException_WithInnerException_PreservesInnerException()
{
var inner = new InvalidOperationException("inner");
var exception = new InvalidEntityStateException("invalid", inner);
Assert.Equal("invalid", exception.Message);
Assert.Same(inner, exception.InnerException);
}
[Fact]
public void AppValidationException_StoresMessage()
{
var exception = new AppValidationException("validation failed");
Assert.Equal("validation failed", exception.Message);
}
}OrderTests.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.UnitTests.Domain;
public sealed class OrderTests
{
[Fact]
public void Create_WhenValidInput_ReturnsOrder()
{
var customerId = Guid.NewGuid();
var productId = Guid.NewGuid();
var order = Order.Create(" tenant-a ", customerId, productId, 2, 25.50m);
Assert.NotEqual(Guid.Empty, order.Id);
Assert.Equal("tenant-a", order.TenantId);
Assert.Equal(customerId, order.CustomerId);
Assert.Equal(productId, order.ProductId);
Assert.Equal(2, order.Quantity);
Assert.Equal(25.50m, order.TotalPrice);
}
[Fact]
public void Create_WhenTenantMissing_ThrowsArgumentException()
{
var exception = Assert.Throws<ArgumentException>(() => Order.Create("", Guid.NewGuid(), Guid.NewGuid(), 1, 1m));
Assert.Equal("tenantId", exception.ParamName);
}
[Fact]
public void Create_WhenCustomerIdEmpty_ThrowsArgumentException()
{
var exception = Assert.Throws<ArgumentException>(() => Order.Create("tenant-a", Guid.Empty, Guid.NewGuid(), 1, 1m));
Assert.Equal("customerId", exception.ParamName);
}
[Fact]
public void Create_WhenProductIdEmpty_ThrowsArgumentException()
{
var exception = Assert.Throws<ArgumentException>(() => Order.Create("tenant-a", Guid.NewGuid(), Guid.Empty, 1, 1m));
Assert.Equal("productId", exception.ParamName);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Create_WhenQuantityInvalid_ThrowsArgumentOutOfRangeException(int quantity)
{
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => Order.Create("tenant-a", Guid.NewGuid(), Guid.NewGuid(), quantity, 1m));
Assert.Equal("quantity", exception.ParamName);
}
[Fact]
public void Create_WhenTotalPriceNegative_ThrowsArgumentOutOfRangeException()
{
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => Order.Create("tenant-a", Guid.NewGuid(), Guid.NewGuid(), 1, -1m));
Assert.Equal("totalPrice", exception.ParamName);
}
[Fact]
public void Update_WhenValidInput_UpdatesQuantityAndPrice()
{
var order = Order.Create("tenant-a", Guid.NewGuid(), Guid.NewGuid(), 1, 10m);
order.Update(3, 30m);
Assert.Equal(3, order.Quantity);
Assert.Equal(30m, order.TotalPrice);
}
[Fact]
public void Update_WhenQuantityInvalid_ThrowsArgumentOutOfRangeException()
{
var order = Order.Create("tenant-a", Guid.NewGuid(), Guid.NewGuid(), 1, 10m);
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => order.Update(0, 1m));
Assert.Equal("quantity", exception.ParamName);
}
[Fact]
public void Update_WhenTotalPriceNegative_ThrowsArgumentOutOfRangeException()
{
var order = Order.Create("tenant-a", Guid.NewGuid(), Guid.NewGuid(), 1, 10m);
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => order.Update(1, -1m));
Assert.Equal("totalPrice", exception.ParamName);
}
}ProductTests.cs
using Ziggy.CRM.Domain.Entities;
namespace Ziggy.CRM.UnitTests.Domain;
public sealed class ProductTests
{
[Fact]
public void Create_WhenValidInput_ReturnsNormalisedProduct()
{
var product = Product.Create(" tenant-a ", " Laptop ", " prd-001 ", 49.99m, "gbp");
Assert.NotEqual(Guid.Empty, product.Id);
Assert.Equal("tenant-a", product.TenantId);
Assert.Equal("Laptop", product.Name);
Assert.Equal("PRD-001", product.Sku);
Assert.Equal(49.99m, product.UnitPrice);
Assert.Equal("GBP", product.Currency);
}
[Theory]
[InlineData("", "Laptop", "PRD-001", 1, "tenantId")]
[InlineData("tenant-a", "", "PRD-001", 1, "name")]
[InlineData("tenant-a", "Laptop", "", 1, "sku")]
public void Create_WhenRequiredInputMissing_ThrowsArgumentException(string tenant, string name, string sku, decimal price, string parameterName)
{
var exception = Assert.Throws<ArgumentException>(() => Product.Create(tenant, name, sku, price));
Assert.Equal(parameterName, exception.ParamName);
}
[Fact]
public void Create_WhenPriceNegative_ThrowsArgumentOutOfRangeException()
{
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => Product.Create("tenant-a", "Laptop", "PRD-001", -1m));
Assert.Equal("unitPrice", exception.ParamName);
}
[Fact]
public void Update_WhenValidInput_UpdatesProduct()
{
var product = Product.Create("tenant-a", "Laptop", "PRD-001", 49.99m);
product.Update(" Updated Laptop ", 99.95m);
Assert.Equal("Updated Laptop", product.Name);
Assert.Equal(99.95m, product.UnitPrice);
}
[Fact]
public void Update_WhenNameMissing_ThrowsArgumentException()
{
var product = Product.Create("tenant-a", "Laptop", "PRD-001", 49.99m);
var exception = Assert.Throws<ArgumentException>(() => product.Update("", 99m));
Assert.Equal("name", exception.ParamName);
}
[Fact]
public void Update_WhenPriceNegative_ThrowsArgumentOutOfRangeException()
{
var product = Product.Create("tenant-a", "Laptop", "PRD-001", 49.99m);
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => product.Update("Laptop", -1m));
Assert.Equal("unitPrice", exception.ParamName);
}
[Fact]
public void ChangePrice_WhenValidInput_UpdatesUnitPrice()
{
var product = Product.Create("tenant-a", "Laptop", "PRD-001", 49.99m);
product.ChangePrice(10.50m);
Assert.Equal(10.50m, product.UnitPrice);
Assert.NotNull(product.UpdatedAtUtc);
}
[Fact]
public void ChangePrice_WhenPriceNegative_ThrowsArgumentOutOfRangeException()
{
var product = Product.Create("tenant-a", "Laptop", "PRD-001", 49.99m);
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => product.ChangePrice(-1m));
Assert.Equal("newPrice", exception.ParamName);
}
}CurrentUserServiceTests.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Ziggy.CRM.Infrastructure.Services;
namespace Ziggy.CRM.UnitTests.Infrastructure;
public sealed class CurrentUserServiceTests
{
[Fact]
public void Properties_WhenHttpContextHasAuthenticatedUser_ReturnExpectedValues()
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-1"),
new Claim("tenant-id", "tenant-a"),
new Claim(ClaimTypes.Role, "admin")
}, "Test");
var service = new CurrentUserService(new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
});
Assert.Equal("user-1", service.UserId);
Assert.Equal("tenant-a", service.TenantId);
Assert.True(service.IsInRole("admin"));
Assert.False(service.IsInRole("manager"));
}
[Fact]
public void Properties_WhenNoHttpContext_ReturnNullAndFalse()
{
var service = new CurrentUserService(new HttpContextAccessor());
Assert.Null(service.UserId);
Assert.Null(service.TenantId);
Assert.False(service.IsInRole("admin"));
}
}TenantProviderTests.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Ziggy.CRM.Infrastructure.Tenancy;
namespace Ziggy.CRM.UnitTests.Infrastructure;
public sealed class TenantProviderTests
{
[Theory]
[InlineData("tenant-id")]
[InlineData("tenant_id")]
[InlineData("tenant")]
[InlineData("tenantid")]
public void Current_WhenAuthenticatedUserHasTenantClaim_ReturnsTenant(string claimType)
{
var provider = CreateProvider(new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(claimType, "tenant-a")
}, "Test")));
Assert.Equal("tenant-a", provider.Current);
}
[Fact]
public void Current_WhenNoHttpContext_ThrowsUnauthorizedAccessException()
{
var provider = new TenantProvider(new HttpContextAccessor());
var exception = Assert.Throws<UnauthorizedAccessException>(() => provider.Current);
Assert.Contains("No active HTTP context", exception.Message);
}
[Fact]
public void Current_WhenUserNotAuthenticated_ThrowsUnauthorizedAccessException()
{
var provider = CreateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
var exception = Assert.Throws<UnauthorizedAccessException>(() => provider.Current);
Assert.Contains("not authenticated", exception.Message);
}
[Fact]
public void Current_WhenTenantClaimMissing_ThrowsUnauthorizedAccessException()
{
var provider = CreateProvider(new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "Test")));
var exception = Assert.Throws<UnauthorizedAccessException>(() => provider.Current);
Assert.Contains("Tenant claim", exception.Message);
}
private static TenantProvider CreateProvider(ClaimsPrincipal principal)
{
return new TenantProvider(new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = principal }
});
}
}KeycloakRoleClaimsTransformerTests.cs
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Ziggy.CRM.Api.Security;
namespace Ziggy.CRM.UnitTests.Security;
public sealed class KeycloakRoleClaimsTransformerTests
{
private readonly IClaimsTransformation _transformer = new KeycloakRoleClaimsTransformer();
[Fact]
public async Task TransformAsync_WhenPrincipalHasNoClaimsIdentity_ReturnsOriginalPrincipal()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var result = await _transformer.TransformAsync(principal);
Assert.Same(principal, result);
}
[Fact]
public async Task TransformAsync_WhenRealmAccessContainsRoles_AddsRoleClaims()
{
var principal = CreatePrincipal(("realm_access", "{\"roles\":[\"admin\",\"manager\"]}"));
var result = await _transformer.TransformAsync(principal);
Assert.True(result.IsInRole("admin"));
Assert.True(result.IsInRole("manager"));
Assert.Contains(result.Claims, x => x.Type == "role" && x.Value == "admin");
}
[Fact]
public async Task TransformAsync_WhenResourceAccessContainsClientRoles_AddsRoleClaims()
{
var json = JsonSerializer.Serialize(new
{
ziggy_api = new { roles = new[] { "user" } },
account = new { roles = new[] { "profile" } }
}).Replace("ziggy_api", "ziggy-api");
var principal = CreatePrincipal(("resource_access", json));
var result = await _transformer.TransformAsync(principal);
Assert.True(result.IsInRole("user"));
Assert.True(result.IsInRole("profile"));
}
[Fact]
public async Task TransformAsync_WhenRoleClaimsAreMissing_DoesNotAddRoles()
{
var principal = CreatePrincipal(("realm_access", "{\"roles\":[]}"), ("resource_access", "{\"ziggy-api\":{}}"));
var result = await _transformer.TransformAsync(principal);
Assert.DoesNotContain(result.Claims, x => x.Type == ClaimTypes.Role);
}
private static ClaimsPrincipal CreatePrincipal(params (string Type, string Value)[] claims)
{
var identity = new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "Test");
return new ClaimsPrincipal(identity);
}
}Integration Test
ApplicationDbContextTenantIsolationTests.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence;
using Ziggy.CRM.Infrastructure.Repositories;
namespace Ziggy.CRM.IntegrationTests.Persistence;
public sealed class ApplicationDbContextTenantIsolationTests
{
[Fact]
public async Task QueryFilters_IsolateCustomersProductsAndOrdersByTenant()
{
var databaseName = Guid.NewGuid().ToString();
await using (var seedDb = CreateContext(databaseName, "tenant-a"))
{
var tenantACustomer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
var tenantAProduct = Product.Create("tenant-a", "Laptop", "prd-a", 100m);
var tenantBCustomer = Customer.Create("tenant-b", "Ayoub", "Rafiq", "[email protected]");
var tenantBProduct = Product.Create("tenant-b", "Mouse", "prd-b", 10m);
seedDb.Customers.AddRange(tenantACustomer, tenantBCustomer);
seedDb.Products.AddRange(tenantAProduct, tenantBProduct);
seedDb.Orders.AddRange(
Order.Create("tenant-a", tenantACustomer.Id, tenantAProduct.Id, 1, 100m),
Order.Create("tenant-b", tenantBCustomer.Id, tenantBProduct.Id, 2, 20m));
await seedDb.SaveChangesAsync();
}
await using var tenantADb = CreateContext(databaseName, "tenant-a");
await using var tenantBDb = CreateContext(databaseName, "tenant-b");
Assert.Single(await tenantADb.Customers.ToListAsync());
Assert.Single(await tenantADb.Products.ToListAsync());
Assert.Single(await tenantADb.Orders.ToListAsync());
Assert.Equal("[email protected]", (await tenantADb.Customers.SingleAsync()).Email);
Assert.Equal("[email protected]", (await tenantBDb.Customers.SingleAsync()).Email);
}
[Fact]
public async Task Repositories_RespectTenantQueryFilterAndPersistEntities()
{
await using var db = CreateContext(Guid.NewGuid().ToString(), "tenant-a");
var customerRepo = new CustomerRepository(db);
var productRepo = new ProductRepository(db);
var orderRepo = new OrderRepository(db);
var customer = Customer.Create("tenant-a", "Ziggy", "Rafiq", "[email protected]");
var product = Product.Create("tenant-a", "Laptop", "prd-a", 100m);
await customerRepo.AddAsync(customer);
await productRepo.AddAsync(product);
await db.SaveChangesAsync();
var order = Order.Create("tenant-a", customer.Id, product.Id, 2, 200m);
await orderRepo.AddAsync(order);
await db.SaveChangesAsync();
Assert.Same(customer, await customerRepo.GetByIdAsync(customer.Id));
Assert.Same(product, await productRepo.GetByIdAsync(product.Id));
Assert.Same(order, await orderRepo.GetByIdAsync(order.Id));
Assert.Single(await customerRepo.ListAsync());
Assert.Single(await productRepo.ListAsync());
Assert.Single(await orderRepo.ListAsync());
orderRepo.Remove(order);
productRepo.Remove(product);
customerRepo.Remove(customer);
await db.SaveChangesAsync();
Assert.Empty(await orderRepo.ListAsync());
Assert.Empty(await productRepo.ListAsync());
Assert.Empty(await customerRepo.ListAsync());
}
private static ApplicationDbContext CreateContext(string databaseName, string tenant)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;
return new ApplicationDbContext(options, new TestTenantProvider(tenant));
}
private sealed class TestTenantProvider(string tenant) : ITenantProvider
{
public string Current { get; } = tenant;
}
}DbContextSeedTests.cs
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.IntegrationTests.Persistence;
public sealed class DbContextSeedTests
{
[Fact]
public async Task SeedAsync_WhenDatabaseIsEmpty_CreatesSeedDataForCurrentTenant()
{
await using var db = CreateContext("tenant_a");
await DbContextSeed.SeedAsync(db);
Assert.Equal(2, await db.Customers.CountAsync());
Assert.Equal(2, await db.Products.CountAsync());
Assert.Equal(2, await db.Orders.CountAsync());
}
[Fact]
public async Task SeedAsync_WhenDatabaseAlreadyHasCustomers_DoesNotDuplicateData()
{
await using var db = CreateContext("tenant_a");
await DbContextSeed.SeedAsync(db);
await DbContextSeed.SeedAsync(db);
Assert.Equal(2, await db.Customers.CountAsync());
Assert.Equal(2, await db.Products.CountAsync());
Assert.Equal(2, await db.Orders.CountAsync());
}
private static ApplicationDbContext CreateContext(string tenant)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options, new TestTenantProvider(tenant));
}
private sealed class TestTenantProvider(string tenant) : ITenantProvider
{
public string Current { get; } = tenant;
}
}Keycloak can be used to test containers.
Real-life flows should be tested.
Architecture Tests
ApiArchitectureTests.cs
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Ziggy.CRM.Api.Controllers;
using Ziggy.CRM.Api.Extensions;
namespace Ziggy.CRM.ArchitectureTests.Rules;
public sealed class ApiArchitectureTests
{
private static readonly Assembly ApiAssembly = typeof(CustomersController).Assembly;
[Fact]
public void Controllers_ShouldUseApiControllerRouteAndAuthorizationAttributes()
{
var controllers = ApiAssembly.GetTypes()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract)
.ToList();
Assert.NotEmpty(controllers);
Assert.All(controllers, controller => Assert.NotNull(controller.GetCustomAttribute<ApiControllerAttribute>()));
Assert.All(controllers, controller => Assert.NotNull(controller.GetCustomAttribute<RouteAttribute>()));
Assert.Contains(controllers, c => c.GetCustomAttribute<AuthorizeAttribute>() is not null);
}
[Fact]
public void MutatingControllerActions_ShouldHaveHttpVerbAttributes()
{
var actionMethods = ApiAssembly.GetTypes()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract)
.SelectMany(t => t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
.Where(m => !m.IsSpecialName)
.ToList();
Assert.NotEmpty(actionMethods);
Assert.All(actionMethods, action => Assert.Contains(action.GetCustomAttributes(), a => a is HttpMethodAttribute));
}
[Fact]
public void ExceptionHandlingExtension_ShouldBeStaticExtensionClass()
{
Assert.True(typeof(ExceptionHandlingExtensions).IsAbstract);
Assert.True(typeof(ExceptionHandlingExtensions).IsSealed);
Assert.Contains(typeof(ExceptionHandlingExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static), m => m.Name == "UseGlobalExceptionHandler");
}
}CleanArchitectureDependencyTests.cs
using System.Reflection;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Ziggy.CRM.Application.Customers.Commands;
using Ziggy.CRM.Domain.Common;
using Ziggy.CRM.Domain.Contracts;
using Ziggy.CRM.Domain.Entities;
using Ziggy.CRM.Infrastructure.Persistence;
namespace Ziggy.CRM.ArchitectureTests.Rules;
public sealed class CleanArchitectureDependencyTests
{
private static readonly Assembly DomainAssembly = typeof(Customer).Assembly;
private static readonly Assembly ApplicationAssembly = typeof(CreateCustomerCommand).Assembly;
private static readonly Assembly InfrastructureAssembly = typeof(ApplicationDbContext).Assembly;
private static readonly Assembly ApiAssembly = typeof(ControllerBase).Assembly;
[Fact]
public void Domain_ShouldNotDependOnApplicationInfrastructureOrApi()
{
AssertAssemblyDoesNotReference(DomainAssembly, "Ziggy.CRM.Application", "Ziggy.CRM.Infrastructure", "Ziggy.CRM.Api");
}
[Fact]
public void Application_ShouldNotDependOnInfrastructureOrApi()
{
AssertAssemblyDoesNotReference(ApplicationAssembly, "Ziggy.CRM.Infrastructure", "Ziggy.CRM.Api");
}
[Fact]
public void Infrastructure_ShouldNotDependOnApi()
{
AssertAssemblyDoesNotReference(InfrastructureAssembly, "Ziggy.CRM.Api");
}
[Fact]
public void DomainEntities_ShouldInheritAuditableEntityAndExposePrivateSetters()
{
var entityTypes = typeof(Customer).Assembly.GetTypes()
.Where(t => t.Namespace == "Ziggy.CRM.Domain.Entities" && t.IsClass)
.ToList();
Assert.NotEmpty(entityTypes);
Assert.All(entityTypes, entity => Assert.True(typeof(AuditableEntity).IsAssignableFrom(entity), $"{entity.Name} must inherit AuditableEntity."));
foreach (var property in entityTypes.SelectMany(t => t.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)))
{
if (property.Name is nameof(Customer.Orders) or nameof(Product.Orders) or nameof(Order.Customer) or nameof(Order.Product))
{
continue;
}
Assert.True(property.SetMethod is null || property.SetMethod.IsPrivate, $"{property.DeclaringType!.Name}.{property.Name} setter must be private or absent.");
}
}
[Fact]
public void ApplicationHandlers_ShouldBeSealedAndImplementMediatRRequestHandler()
{
var handlers = ApplicationAssembly.GetTypes()
.Where(t => t.Name.EndsWith("Handler", StringComparison.Ordinal) && t.IsClass)
.ToList();
Assert.NotEmpty(handlers);
Assert.All(handlers, handler => Assert.True(handler.IsSealed, $"{handler.Name} should be sealed."));
Assert.All(handlers, handler => Assert.Contains(handler.GetInterfaces(), i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>)));
}
[Fact]
public void CommandsAndQueries_ShouldBeRecordsAndLiveInApplicationLayer()
{
var messages = ApplicationAssembly.GetTypes()
.Where(t => t.Name.EndsWith("Command", StringComparison.Ordinal) || t.Name.EndsWith("Query", StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(messages);
Assert.All(messages, message => Assert.StartsWith("Ziggy.CRM.Application", message.Namespace));
Assert.All(messages, message => Assert.True(message.GetMethods().Any(m => m.Name == "<Clone>$"), $"{message.Name} should be a C# record."));
}
[Fact]
public void InfrastructureDbContext_ShouldImplementApplicationDbContextContract()
{
Assert.True(typeof(IApplicationDbContext).IsAssignableFrom(typeof(ApplicationDbContext)));
Assert.True(typeof(DbContext).IsAssignableFrom(typeof(ApplicationDbContext)));
}
private static void AssertAssemblyDoesNotReference(Assembly assembly, params string[] forbiddenAssemblies)
{
var referencedNames = assembly.GetReferencedAssemblies().Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
foreach (var forbidden in forbiddenAssemblies)
{
Assert.DoesNotContain(forbidden, referencedNames);
}
}
}Keycloak
As we have used Docker Command in the termial we do not need to use the yaml file of setting up the Keycloak. However for learning purpose I have add the keycloak.yaml file code below.
version: "3.9"
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start
environment:
KC_DB: postgres
KC_DB_URL_HOST: postgres
KC_DB_URL_DATABASE: keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: Admin123!
ports:
- "9090:8080"
depends_on:
- postgres
postgres:
image: postgres:15
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloakCommon Mistakes I See in Production
It appears again and again after reviewing many systems:
❌ Password storage that is customised
❌ Tenant filters are not available
❌ Admin accounts that can be shared
❌ Audits are not conducted
❌ Secrets that are hardcoded
Stay away from them.
Later on, they become expensive.
Deployment Best Practices
System deployment is the first step towards security and reliability in a production environment. A well-designed deployment architecture ensures that applications remain scalable, resilient, and protected against operational and security risks. The ASP.NET Core API should be containerized with Docker and orchestrated with Kubernetes for this reason. In addition to providing consistency across environments, this approach allows automatic scaling, supports rolling deployments, and ensures that failed services can be recovered automatically.
In order for Keycloak to function efficiently, it must be deployed as a high-availability cluster rather than as a single instance. Authentication is an integral part of the entire platform, and any downtime directly impacts the users. In addition to fault tolerance, improved performance under load, and zero-downtime upgrades, multiple Keycloak nodes are backed by a load balancer and a shared and resilient database.
A centralized secrets vault is essential for the management of all sensitive configuration values, such as database credentials, API keys, and client secrets. Storing them in source code, configuration files, or repositories poses serious security risks. Using tools such as HashiCorp Vault, Azure Key Vault, or managed cloud secret services allows secrets to be encrypted, rotated, audited, and accessed securely at runtime.
The security of transport must be enforced across all system components with TLS encryption. Every connection between clients, APIs, identity services, and internal services should utilize HTTPS with modern TLS standards. In addition to ensuring compliance with security and data protection regulations, this prevents token interception, credential theft, and man-in-the-middle attacks.
It takes more than application code to create a secure and reliable system. It requires careful deployment design, robust infrastructure, strong secret management, and encrypted communication to achieve it. In modern enterprise systems, security begins at the deployment layer when these practices are consistently applied. In modern enterprise systems, security becomes a part of the platform rather than an afterthought.
| Component | Recommendation |
|---|---|
| API | Docker + Kubernetes |
| Keycloak | HA Cluster |
| Secrets | Vault |
| TLS | Mandatory |
| Security | Starts in deployment. |
Business Value
This architecture gives:
For Companies
Legal compliance
Customer trust
Easy scaling
For Engineers
Clean code
Fewer bugs
Safer changes
For Users
Reliable platform
Secure data
ASP.NET Core 10.0 Hosting Recommendation
At HostForLIFE.eu, customers can also experience fast ASP.NET Core hosting. The company invested a lot of money to ensure the best and fastest performance of the datacenters, servers, network and other facilities. Its datacenters are equipped with the top equipments like cooling system, fire detection, high speed Internet connection, and so on. That is why HostForLIFEASP.NET guarantees 99.9% uptime for ASP.NET Core. And the engineers do regular maintenance and monitoring works to assure its Orchard hosting are security and always up.


