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 is ready if you see a version number.
Running Keycloak with Docker
Run the following command in PowerShell:
The following is the result of this command:
Keycloak can be downloaded
An admin user is created
Keycloak is run in development mode
Port 9090 is made available
Keycloak will start after a few seconds.
Opening the Admin Console
Go to the following website in your browser:
http://localhost:9090
The admin panel consists of:
http://localhost:9090/admin
Login with:
Username: admin
Password: Admin123!
The Keycloak dashboard should now appear.
Checking That Keycloak Is Running
Run the following commands to ensure everything is working:
A container named keycloak should appear.
Check the logs if something looks wrong:
docker logs -f keycloak
Creating a Realm for Our System
Users and clients are separated by realms. Each system should have its own realm.
The Admin Console shows:
Select Master from the menu
Create a realm by selecting it
Please enter:
Name: ziggy
Click Create
ZiggyCRM will use this realm.
Creating an API Client
Our ASP.NET API is now registered.
Open Clients
Click Create Client
Enter:
Client ID: ziggy-api
Select OpenID Connect
Click Save
Enable:
Client Authentication
Authorization
Save it again.
We will use the Client Secret in our API later.
Creating User Roles
Here's what goes on inside Ziggy:
Open Realm Roles
Create these roles:
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:
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:
In Program.cs (development only):
options.RequireHttpsMetadata = false;
In production, HTTPS should not be disabled.
Stopping and Restarting Keycloak
To stop Keycloak:
To remove it:
You can restart Docker by running the command again.
Your environment can be reset easily this way.
Why This Setup Works Well
As a result of running Keycloak with Docker, we are able to:
Every developer has the same setup
Installation is easy
Resetting quickly
Configuration issues are fewer
Matching production better
Teams can work faster and avoid security mistakes this way.
This way, we set up Keycloak before writing any application code, keeping our system secure, consistent, and easy to maintain.
Step 1: Configuring JWT Authentication
Our API only trusts Keycloak tokens.
In Program.cs:
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:
The token tells us everything we need to know:
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:
Customer.cs
The following is a proper Customer entity:
Product.cs
The following is a proper Product entity:
Order.cs
The following is a proper Order entity:
Email.cs
The following is a proper Email ValueObject
IApplicationDbContext.cs
The following is a proper IApplicationDbContext Contract
ICurrentUserService.cs
The following is a proper ICurrentUserService Contract
IRepository.cs
The following is a proper IRepository Contract
ITenantProvider.cs
The following is a proper ITenantProvider Contract
AppValidationException.cs
The following is a proper AppValidationException Exception
DomainException.cs
The following is a proper DomainException Exception
EntityNotFoundException.cs
The following is a proper EntityNotFoundException Exception
ErrorResponse.cs
The following is a proper ErrorResponse Exception
InvalidEntityStateException.cs
The following is a proper InvalidEntityStateException Exception
AuthTokenResponse.cs
The following is a proper AuthTokenResponse Auth
KeycloakTokenResponse.cs
The following is a proper KeycloakTokenResponse Auth
LoginRequest.cs
The following is a proper LoginRequest Auth
RefreshTokenRequest.cs
The following is a proper RefreshTokenRequest Auth
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:
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
There is only one source of truth.
CurrentUserService.cs
ApplicationDbContext.cs (Global Query Filter)
Each query is now isolated.
As well as this:
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
OrderConfiguration.cs
ProductConfiguration.cs
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
OrderRepository.cs
ProductRepository.cs
CustomerSeed.cs
OrderSeed.cs
ProductSeed.cs
TenantSeedData.cs
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.
This is important for the following reasons:
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
DeleteCustomerCommand.cs
UpdateCustomerCommand.cs
Handlers
CreateCustomerCommandHandler.cs
DeleteCustomerCommandHandler.cs
GetCustomerByIdQueryHandler.cs
GetCustomersQueryHandler.cs
UpdateCustomerCommandHandler.cs
Queries
GetCustomerByIdQuery.cs
GetCustomersQuery.cs
Dtos
CustomerDto.cs
Orders
Commands
CreateOrderCommand.cs
DeleteOrderCommand.cs
UpdateOrderCommand.cs
Handlers
CreateOrderCommandHandler.cs
DeleteOrderCommandHandler.cs
GetOrderByIdQueryHandler.cs.
GetOrdersQueryHandler.cs
UpdateOrderCommandHandler.cs
Queries
GetOrderByIdQuery.cs
GetOrdersQuery.cs
Dtos
OrderDto.cs
Products
Commands
CreateProductCommand.cs
DeleteProductCommand.cs
UpdateProductCommand.cs
Handlers
CreateProductCommandHandler.cs
DeleteProductCommandHandler.cs
GetProductByIdQueryHandler.cs
GetProductsQueryHandler.cs
UpdateProductCommandHandler.cs
Queries
GetProductByIdQuery.cs
GetProductsQuery.cs
Dtos
ProductDto.cs
What are the benefits of CQRS?
Service Pattern
Now we are following the Service Patterrn as following
IKeycloakAuthService.cs
KeycloakAuthService.cs
Step 7: Securing Endpoints Properly
Security
KeycloakRoleClaimsTransformer.cs
Middleware
ExceptionHandlingMiddleware.cs
Extensions
ExceptionHandlingExtensions.cs
Controllers
DebugController.cs
AuthController.cs
OrdersController.cs
ProductsController.cs
Program.cs
appsettings.json
Ziggy.CRM.Api.http
The best practices are:
Role-based policies should be used
Manual checks should be avoided
Rules should be centralized
Step 8: Auditing for Compliance
You get the following benefits with Keycloak IDs:
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
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
TenantProviderTests.cs
KeycloakRoleClaimsTransformerTests.cs
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
Keycloak can be used to test containers.
Real-life flows should be tested.
Architecture Tests
ApiArchitectureTests.cs
CleanArchitectureDependencyTests.cs
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.
Common 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
One of the most important things when choosing a good ASP.NET Core 9.0 hosting is the feature and reliability.
HostForLIFE
is the leading provider of Windows hosting and affordable ASP.NET Core, their
servers are optimized for PHP web applications. The performance and the uptime of the hosting service are excellent
and the features of the web hosting plan are even greater than what many
hosting providers ask you to pay for.
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.