
Architectural patterns are fundamental blueprints that guide developers in structuring applications, ensuring scalability, maintainability, and robustness. In the .NET, several architectural patterns have proven instrumental for developing efficient and effective software solutions. This article dives into the key .NET architectural patterns, exploring their benefits, and providing a practical code walkthrough.
- Model-View-Controller (MVC) — Separates an application into three components (Model, View, and Controller) to promote modularity, maintainability, and separation of concerns.
- Model-View-ViewModel (MVVM) — Enhances separation of concerns by introducing a ViewModel that acts as an intermediary between the View and Model, facilitating data binding and UI responsiveness.
- Hexagonal (Ports & Adapters) Architecture — Decouples business logic from external dependencies by using ports and adapters, making applications more flexible and testable.
- CQRS (Command Query Responsibility Segregation) — Splits read and write operations into separate models to optimize performance, scalability, and maintainability.
- Event Sourcing — Stores system state as a sequence of immutable events, enabling full history tracking, auditing, and easier rollback of changes.
- Domain-Driven Design (DDD) — Structures software around domain concepts using rich models, ubiquitous language, and bounded contexts to align with business needs.
- Microservices Pattern — Breaks down an application into small, independently deployable services, improving scalability, maintainability, and resilience.
Model-View-Controller (MVC) — Decoupling UI, Business Logic, and Data
The Model-View-Controller (MVC) pattern separates an application into three interconnected components:
- Model: Manages the application’s data and business logic.
- View: Handles the display of information to the user.
- Controller: Processes user input and interacts with the Model to render the appropriate View.
ASP.NET Core utilizes the MVC pattern to build dynamic web applications and APIs, promoting a clear separation of concerns.
- Separation of Concerns: Enhances code maintainability by isolating business logic, UI, and input control.
- Testability: Facilitates unit testing of individual components.
- Scalability: Allows independent development and scaling of components.
Common Usages
- Web Applications: Enables organized development of complex web interfaces.
- APIs: Structures API endpoints efficiently.
- Single Page Applications (SPAs): Manages dynamic content rendering.
SOLID Principles
- Single Responsibility Principle: Each component has a distinct responsibility.
- Open/Closed Principle: Components can be extended without modifying existing code.
- Dependency Inversion Principle: Controllers depend on abstractions, not concrete implementations.
💡Implementing the MVC Pattern: Product Management Application
In this example, we build a simple Product Management application using the Model-View-Controller (MVC) pattern. This example demonstrates how to structure an application by separating concerns — handling data with the Model, managing user interactions with the Controller, and presenting information with the View — resulting in a maintainable and scalable solution.
1️⃣Define the Model
This code defines a Product class, which serves as the Model in the MVC pattern. It represents a product entity.
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
This class encapsulates the data structure for products and is typically used to interact with a database, ensuring a clean separation of concerns between data representation and application logic.
2️⃣Create the Controller
This code defines a ProductsController class, which serves as the Controller in the MVC pattern. It manages product-related user interactions and connects the Model (data) with the View (UI).
This controller ensures that business logic remains separate from the view, promoting maintainability and testability.
public class ProductsController : Controller
{
private readonly IProductRepository _repository;
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
public IActionResult Index()
{
var products = _repository.GetAllProducts();
return View(products);
}
}
- It injects an IProductRepository dependency, following the Dependency Inversion Principle (DIP) to allow for flexible data access.
- The Index() action retrieves all products from the repository using
_repository.GetAllProducts()
. - It returns a View populated with the list of products, passing the data to be rendered in the UI.
3️⃣Develop the View
This code defines a View in the MVC pattern, responsible for displaying a list of products in a structured table format.
This view cleanly separates presentation logic from business logic, ensuring maintainability and readability.
@model IEnumerable<Product>
<h1>Product List</h1>
<table>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
@foreach (var product in Model)
{
<tr>
<td>@product.Name</td>
<td>@product.Price</td>
</tr>
}
</table>
- The
@model IEnumerable<Product>
directive at the top specifies that this view expects a collection of Product objects as its data source. - It renders an
<h1>
header titled “Product List” for clarity. - A
<table>
is created to display product details, with column headers for Name and Price. - The
@foreach
loop iterates through the Model (a collection ofProduct
objects) and dynamically generates table rows (<tr>
) displaying each product’s Name and Price.
4️⃣Implement the Repository
This code defines the Repository layer, which abstracts data access in the MVC pattern, ensuring a clean separation between the application logic and the data source.
By using dependency injection (DI), this approach follows the Dependency Inversion Principle (DIP), making the code more flexible, testable, and maintainable.
public interface IProductRepository
{
IEnumerable<Product> GetAllProducts();
}
public class ProductRepository : IProductRepository
{
public IEnumerable<Product> GetAllProducts()
{
// Retrieve products from the data source
}
}
IProductRepository
(Interface) – Declares a contract for data operations, specifying a methodGetAllProducts()
that returns a collection ofProduct
objects.ProductRepository
(Concrete Implementation) – ImplementsIProductRepository
, providing the actual logic to retrieve products from a data source (e.g., a database or in-memory collection).
5️⃣Configure Dependency Injection
This code configures dependency injection (DI) and MVC services in the application’s startup configuration.
This setup ensures loose coupling and testability, allowing the application to inject IProductRepository
wherever needed without tightly coupling it to a specific implementation.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddScoped<IProductRepository, ProductRepository>();
}
services.AddControllersWithViews();
– Registers MVC services, enabling the application to handle controllers and views.services.AddScoped<IProductRepository, ProductRepository>();
– RegistersIProductRepository
with its concrete implementationProductRepository
using scoped dependency injection, ensuring a new instance is created per request.
Model-View-ViewModel (MVVM) — Used in Blazor, WPF, and MAUI
The Model-View-ViewModel (MVVM) pattern is an evolution of MVC, tailored for modern UI development platforms like WPF, Blazor, and MAUI. It introduces the ViewModel to manage the presentation logic and state.
- Enhanced Separation of Concerns: Isolates the UI from business logic more effectively.
- Improved Testability: Allows for unit testing of UI logic without UI dependencies.
- Data Binding: Facilitates automatic synchronization between the View and ViewModel.
Common Usages
- Desktop Applications: Utilizes data binding in WPF for responsive UIs.
- Cross-Platform Applications: Employs MAUI to share code across platforms.
- Single Page Applications (SPAs): Leverages Blazor for dynamic web interfaces.
SOLID Principles
- Single Responsibility Principle: ViewModel handles presentation logic; View manages UI rendering.
- Open/Closed Principle: ViewModels can be extended for new functionalities without altering existing code.
- Dependency Inversion Principle: Views and ViewModels depend on abstractions, enhancing flexibility
💡Implementing MVVM in Blazor
In this walkthrough, we’ll implement the Model-View-ViewModel (MVVM) pattern in a Blazor application, demonstrating how to separate concerns for better maintainability and testability. The Model represents the data, the ViewModel manages state and business logic, and the View handles UI rendering and user interactions. By leveraging data binding and dependency injection, MVVM enables a clean, reactive architecture that enhances Blazor applications’ scalability and reusability.
1️⃣Create the Model
This code defines a Product class, which serves as the Model in the MVVM pattern. It represents the data structure for a product with three properties:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
In the MVVM pattern, the Model is responsible for representing the raw data, which is then manipulated and displayed by the ViewModel and View components, ensuring clear separation of concerns and easier maintenance.
2️⃣Create the ViewModel
This code defines a ProductViewModel class, which serves as the ViewModel in the MVVM pattern. The ViewModel is responsible for managing the application’s logic and state, as well as providing data for the View to render.
This ViewModel acts as the intermediary between the Model (data) and the View, handling data manipulation and preparing the data for display without directly interacting with the UI. It ensures that logic and state management are kept separate from the View.
public class ProductViewModel
{
public List<Product> Products { get; set; } = new();
public void AddProduct(string name, decimal price)
{
Products.Add(new Product { Id = Products.Count + 1, Name = name, Price = price });
}
}
Products
– A list ofProduct
objects representing the collection of products in the application.AddProduct()
– A method that adds a newProduct
to theProducts
list. It creates a new product instance with a uniqueId
, aName
, and aPrice
passed as parameters.
3️⃣Use ViewModel in Blazor Component
This code defines the View in the MVVM pattern, responsible for rendering the UI and binding it to the ViewModel.
This view displays the data and provides user interaction via the AddProduct
button, while delegating all the business logic and state management to the ViewModel, in line with the MVVM pattern’s separation of concerns.
@inject ProductViewModel ViewModel
<h3>Product List</h3>
<ul>
@foreach (var product in ViewModel.Products)
{
<li>@product.Name - [email protected]</li>
}
</ul>
<button @onclick="() => ViewModel.AddProduct('New Product', 10.99)">Add Product</button>
@inject ProductViewModel ViewModel
– Injects theProductViewModel
into the view, allowing it to access the data and logic defined in the ViewModel.<h3>Product List</h3>
– Displays a heading for the product list.<ul>
– Uses aforeach
loop to iterate through theProducts
collection in the ViewModel and displays each product’s Name and Price in a list item (<li>
).<button @onclick="() => ViewModel.AddProduct('New Product', 10.99)">Add Product</button>
– A button that triggers the ViewModel’sAddProduct()
method when clicked, adding a new product to the list with a default name and price.
Hexagonal (Ports & Adapters) Architecture — Decoupling Core Logic from Infrastructure
Hexagonal Architecture (also known as Ports and Adapters) ensures that core application logic is independent of external systems such as databases, APIs, or UIs.
- Decoupling — Business logic remains independent of infrastructure.
- Flexibility — Adapters allow easy integration with new technologies.
- Testability — Core logic can be tested without external dependencies.
Common Usages
- Enterprise Applications — Separating domain logic from infrastructure.
- Microservices — Making services independent of specific databases.
- Plugin-Based Systems — Allowing for flexible integrations.
SOLID Principles
- Dependency Inversion Principle — High-level modules do not depend on low-level modules.
- Open/Closed Principle — Infrastructure components can be changed without modifying business logic.
💡Implementing Ports & Adapters-Building a User Authentication System
In this example, we build a simple user authentication system where the core business logic (authentication) is isolated from external dependencies like a database or an authentication service. This allows the core logic to remain independent, easily testable, and adaptable to different infrastructure implementations.
1️⃣Step 1: Define the Core Business Logic
We’ll start by defining the business logic for user authentication, focusing on a simple use case: verifying a user’s credentials.
// Core Business Logic - AuthenticationService
public interface IAuthenticationService
{
bool Authenticate(string username, string password);
}
public class AuthenticationService : IAuthenticationService
{
public bool Authenticate(string username, string password)
{
// Simple logic for the sake of this example: in real life, hash passwords and check credentials.
return username == "admin" && password == "password123";
}
}
AuthenticationService
contains the core business logic that verifies whether a username and password match. This logic does not know how the data is stored or where it comes from—it simply takes the inputs and returns a boolean result.
2️⃣Define the Adapter to Handle Input (The Adapter)
Next, we’ll create an adapter that acts as a bridge between the core logic and the external systems, in this case, a user interface or an API.
// Adapter for handling input (UI or API)
public class UserAuthenticationAdapter
{
private readonly IAuthenticationService _authenticationService;
public UserAuthenticationAdapter(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public bool HandleLogin(string username, string password)
{
return _authenticationService.Authenticate(username, password);
}
}
This adapter knows how to interact with the core AuthenticationService
to execute the authentication process. It doesn’t implement the core logic but delegates the work to the core.
3️⃣Define the Port (The Port)
In Hexagonal Architecture, the port defines an interface that external components (e.g., the user interface, database, or API) use to interact with the core logic. The adapter implements this port, allowing communication with the outside world.
// Port - Defines how external systems (UI/API) interact with the core business logic
public interface ILoginHandler
{
bool AuthenticateUser(string username, string password);
}
The ILoginHandler
interface defines the contract that external systems will use to communicate with the core authentication logic.
4️⃣Implement the Adapter to the Port
Now, we implement the adapter that connects the port to the core.
// Implementation of the port through an adapter
public class LoginHandler : ILoginHandler
{
private readonly UserAuthenticationAdapter _authenticationAdapter;
public LoginHandler(UserAuthenticationAdapter authenticationAdapter)
{
_authenticationAdapter = authenticationAdapter;
}
public bool AuthenticateUser(string username, string password)
{
return _authenticationAdapter.HandleLogin(username, password);
}
}
This LoginHandler
class implements the ILoginHandler
port, and it uses the UserAuthenticationAdapter
to call the core authentication logic.
5️⃣Tie Everything Together (The Application)
Finally, in a real-world application, this would tie everything together: the core business logic, adapter, and port can be injected into the controller, UI, or API.
// Application - Wiring up the port, adapter, and core logic
class Program
{
static void Main(string[] args)
{
// Setup the core, adapter, and port
IAuthenticationService authenticationService = new AuthenticationService();
UserAuthenticationAdapter adapter = new UserAuthenticationAdapter(authenticationService);
ILoginHandler loginHandler = new LoginHandler(adapter);
// Simulate a user login attempt
string username = "admin";
string password = "password123";
bool isAuthenticated = loginHandler.AuthenticateUser(username, password);
Console.WriteLine(isAuthenticated ? "Login Successful!" : "Login Failed!");
}
}
- Core (Business Logic): The
AuthenticationService
class contains the actual logic to validate user credentials. - Port: The
ILoginHandler
interface is the point through which external systems (like a UI) interact with the core logic. - Adapter: The
UserAuthenticationAdapter
bridges the gap between the port and core business logic. It connects the core logic to the outside world (whether it’s a web API or a UI). - Application: The
Main
method ties everything together, simulating a login attempt with predefined credentials.
CQRS (Command Query Responsibility Segregation) — Optimize Read/Write Operations
CQRS separates read and write operations into different models, improving performance and scalability.
- Performance Optimization — Read operations are optimized separately from writes.
- Scalability — Write-heavy and read-heavy operations can scale independently.
- Security — Fine-grained control over data access.
Common Usages
- E-Commerce Applications — Fast product searches vs. complex order processing.
- Event-Driven Systems — Processing events separately from querying data.
- Financial Applications — Audit logs and transactional consistency.
SOLID Principles
- Single Responsibility Principle — Commands and queries serve distinct purposes.
- Open/Closed Principle — Read and write models can evolve independently.
💡Implementing CQRS for Order Management
In this example, we explore the Command Query Responsibility Segregation (CQRS) pattern by implementing it in a simple Order Management System. In Step 1, we’ll define the Command for creating an order, separating the write model from the read model. This step demonstrates how CQRS improves scalability and maintainability by isolating operations that modify data from those that retrieve it, leading to a cleaner and more efficient system architecture.
1️⃣Define Command (Write Model)
This code defines a CreateOrderCommand, which represents the Command in the CQRS pattern, specifically the write model.
public class CreateOrderCommand
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}
In CQRS, commands like CreateOrderCommand
are used to encapsulate data required to perform a specific action (in this case, creating an order). The CreateOrderCommand
is used to pass this data to a handler that processes the order and modifies the system’s state. By separating the command (write model) from queries (read model), the system can more efficiently scale and manage read and write operations independently.
2️⃣Define Query (Read Model)
This code defines a GetOrderQuery, which represents the Query in the CQRS pattern, specifically the read model.
In CQRS, queries like GetOrderQuery
are used to retrieve data without modifying the system’s state. The GetOrderQuery
is used to request information about a specific order, and the system will return the corresponding data without performing any changes. By separating queries from commands, the system can optimize the read and write operations independently, ensuring better performance and scalability.
public class GetOrderQuery
{
public int OrderId { get; set; }
}
OrderId
(integer) – Represents the unique identifier of the order that the user wants to retrieve.
3️⃣Implement Handlers
This code defines two handler classes, OrderCommandHandler and OrderQueryHandler, which are responsible for processing commands and queries in the CQRS pattern. Each handler interacts with the appropriate model (write or read) and performs the respective actions.
By separating the responsibility of handling commands (write operations) from queries (read operations), the CQRS pattern allows better scalability and optimization of both operations. The command handler focuses on altering the system’s state, while the query handler is solely concerned with fetching data without any side effects.
public class OrderCommandHandler
{
public void Handle(CreateOrderCommand command)
{
// Save order to database
}
}
public class OrderQueryHandler
{
public Order Handle(GetOrderQuery query)
{
// Retrieve order details
return new Order();
}
}
OrderCommandHandler
– This handler processes the command (CreateOrderCommand
) that modifies the system’s state:
- The
Handle
method receives aCreateOrderCommand
, which contains the necessary data to create a new order. - Inside the method, it would typically save the order to a database, although the actual saving logic is not shown here.
OrderQueryHandler
– This handler processes the query (GetOrderQuery
) that retrieves data from the system:
- The
Handle
method receives aGetOrderQuery
, which contains theOrderId
for the order to be fetched. - It retrieves the order details, typically from a read-optimized data store, and returns an
Order
object with the requested data.
Event Sourcing — Storing State Changes as Events for Better Traceability
In this pattern, Event Sourcing stores the history of state changes as a series of immutable events instead of using traditional database updates. Each event represents a state transition, allowing you to reconstruct the entire history of an entity’s state over time. This approach enhances traceability, enables easier rollback to previous states, and provides a natural audit trail. By using event-driven architecture, Event Sourcing supports scalability and resilience while maintaining full visibility into the evolution of the application’s data.
- Complete Audit Log — Every state change is recorded.
- Rebuild State — The current state can be reconstructed from past events.
- Scalability — Works well with distributed systems.
Common Usages
- Financial Transactions — Immutable history of operations.
- Real-Time Systems — Maintaining state in event-driven applications.
- Distributed Systems — Synchronizing state across microservices.
SOLID Principles
- Single Responsibility Principle — Events capture domain changes clearly.
- Open/Closed Principle — New event types can be added without modifying existing logic.
💡Implement Event Sourcing for an Order Processing System
We want to track the state changes of an order as events. For example, when an order is placed, an order event is recorded. When the order is shipped, another event is recorded. Event Sourcing allows us to keep a history of these changes, making it easy to roll back, view the order’s lifecycle, or rebuild the state at any point in time.
1️⃣Define the Event (State Change)
We start by defining events that represent changes in the order’s lifecycle, such as placing an order and shipping it.
public class OrderPlacedEvent
{
public int OrderId { get; set; }
public DateTime OrderDate { get; set; }
public string CustomerName { get; set; }
}
public class OrderShippedEvent
{
public int OrderId { get; set; }
public DateTime ShipDate { get; set; }
}
- We define two events:
OrderPlacedEvent
andOrderShippedEvent
. - Each event captures the relevant data related to the state change in the order’s lifecycle.
- These events will be stored in an event store (e.g., a database or a messaging system).
2️⃣Create the Order Aggregate Root
The aggregate root (in this case, Order) is responsible for applying events and maintaining the state of the entity.
public class Order
{
public int OrderId { get; private set; }
public string CustomerName { get; private set; }
public DateTime OrderDate { get; private set; }
public DateTime? ShipDate { get; private set; }
private List<object> _changes = new List<object>(); // List of events
public Order(int orderId, string customerName)
{
Apply(new OrderPlacedEvent
{
OrderId = orderId,
CustomerName = customerName,
OrderDate = DateTime.Now
});
}
public void ShipOrder()
{
Apply(new OrderShippedEvent
{
OrderId = OrderId,
ShipDate = DateTime.Now
});
}
// Apply an event and track it
private void Apply(object @event)
{
// Store the event to track changes
_changes.Add(@event);
// Apply the event to the current state
When(@event);
}
// Apply the event to the entity's state
private void When(object @event)
{
switch (@event)
{
case OrderPlacedEvent placedEvent:
OrderId = placedEvent.OrderId;
CustomerName = placedEvent.CustomerName;
OrderDate = placedEvent.OrderDate;
break;
case OrderShippedEvent shippedEvent:
ShipDate = shippedEvent.ShipDate;
break;
}
}
public IEnumerable<object> GetChanges()
{
return _changes;
}
}
- The
Order
class represents the aggregate root that handles events. - The constructor accepts the necessary data to create a new order and applies an
OrderPlacedEvent
to record the creation. - The
ShipOrder
method creates anOrderShippedEvent
to mark the order as shipped and applies it. - The
Apply
method adds the event to an internal list (_changes
) and callsWhen
to apply the event’s data to the order’s state. - The
GetChanges
method provides access to the events that have been applied to the order.
3️⃣Event Store — Store and Retrieve Events
The Event Store is responsible for saving and retrieving the events. This allows us to rebuild the state of an order from its events at any point in time.
public class InMemoryEventStore
{
private readonly Dictionary<int, List<object>> _eventStore = new();
public void SaveEvents(int aggregateId, IEnumerable<object> events)
{
if (!_eventStore.ContainsKey(aggregateId))
{
_eventStore[aggregateId] = new List<object>();
}
_eventStore[aggregateId].AddRange(events);
}
public IEnumerable<object> GetEvents(int aggregateId)
{
return _eventStore.ContainsKey(aggregateId) ? _eventStore[aggregateId] : new List<object>();
}
}
- The
InMemoryEventStore
stores events in memory, indexed by the aggregate ID (OrderId
). - The
SaveEvents
method appends new events to the store for a particular order. - The
GetEvents
method retrieves all events for a specific order, allowing the system to rebuild the order’s state by replaying the events.
4️⃣Rebuilding the Order State from Events
Using the events stored in the event store, we can rebuild the order’s state at any point in time.
public class OrderService
{
private readonly InMemoryEventStore _eventStore;
public OrderService(InMemoryEventStore eventStore)
{
_eventStore = eventStore;
}
public Order GetOrderById(int orderId)
{
var events = _eventStore.GetEvents(orderId);
var order = new Order(0, string.Empty); // Initial state is empty
foreach (var @event in events)
{
// Rebuild the order state by applying each event
order.Apply(@event);
}
return order;
}
public void SaveOrder(Order order)
{
_eventStore.SaveEvents(order.OrderId, order.GetChanges());
}
}
- The
OrderService
retrieves an order’s events from the event store usingGetEvents
. - It then rebuilds the order’s state by applying each stored event using the
Apply
method. - The
SaveOrder
method saves the order’s events back to the event store, ensuring that state changes are captured for future use.
5️⃣Simulate an Order Lifecycle
Finally, let’s simulate the lifecycle of an order by placing an order, shipping it, and retrieving the order’s state from the event store.
class Program
{
static void Main()
{
var eventStore = new InMemoryEventStore();
var orderService = new OrderService(eventStore);
// Create a new order
var order = new Order(1, "John Doe");
orderService.SaveOrder(order); // Save initial order placed event
// Ship the order
order.ShipOrder();
orderService.SaveOrder(order); // Save order shipped event
// Rebuild the order from events
var rebuiltOrder = orderService.GetOrderById(1);
Console.WriteLine($"Order {rebuiltOrder.OrderId} for {rebuiltOrder.CustomerName} placed on {rebuiltOrder.OrderDate} and shipped on {rebuiltOrder.ShipDate}");
}
}
- An order is created with the customer’s name, and the
OrderPlacedEvent
is saved. - The order is then shipped, and the
OrderShippedEvent
is saved. - We then retrieve the order’s events from the event store and rebuild its state by applying the stored events.
- Finally, we output the order details, showing that the state was correctly reconstructed using the stored events.
Domain-Driven Design (DDD) — Structuring Applications Around Business Logic
Domain-Driven Design (DDD) focuses on organizing software around real-world business domains, ensuring that the structure of the application reflects the complexities of the business it serves. By using concepts such as entities, value objects, aggregates, and bounded contexts, DDD helps improve the clarity, maintainability, and scalability of an application. This approach fosters better collaboration between technical and non-technical stakeholders and ensures that the software accurately models the underlying business processes.
- Business-Centric Design — Aligns software with real-world processes.
- Encapsulation — Reduces complexity with domain models.
- Scalability — Facilitates modular development.
Common Usages
- Enterprise Applications — Organizing complex business processes.
- Microservices — Defining clear service boundaries.
- E-Commerce Systems — Managing product catalogs and orders.
SOLID Principles
- Single Responsibility Principle — Business logic is contained within domain entities.
- Dependency Inversion Principle — Business logic depends on abstractions.
💡Implementing DDD for an Order Management System
In this scenario, we will implement an Order Management System where orders can be placed, items can be added to the order, and the order’s status can be tracked. We’ll model the business logic and domain entities using DDD principles to keep the code organized and maintainable.
1️⃣Define the Domain Entity — Order
In DDD, Entities are objects that have a distinct identity. The Order
entity will be the core of our system.
public class Order
{
public int OrderId { get; private set; }
public string CustomerName { get; private set; }
public List<OrderItem> Items { get; private set; }
public OrderStatus Status { get; private set; }
public Order(int orderId, string customerName)
{
OrderId = orderId;
CustomerName = customerName;
Items = new List<OrderItem>();
Status = OrderStatus.Created;
}
public void AddItem(OrderItem item)
{
Items.Add(item);
}
public void MarkShipped()
{
Status = OrderStatus.Shipped;
}
public void MarkDelivered()
{
Status = OrderStatus.Delivered;
}
}
- The
Order
class is an Entity in DDD because it has a distinct identity (OrderId
). - It holds properties for the customer name, the list of items (
OrderItem
), and the status of the order (OrderStatus
). - The constructor initializes the order with an
OrderId
andCustomerName
. - The
AddItem
method allows us to addOrderItem
to the order. - The
MarkShipped
andMarkDelivered
methods modify the order’s status.
2️⃣Define the Value Object — OrderItem
In DDD, Value Objects are immutable objects that don’t have a distinct identity and are typically used to describe attributes of an entity. Here, the OrderItem
is a value object.
public class OrderItem
{
public string ProductName { get; }
public int Quantity { get; }
public decimal Price { get; }
public OrderItem(string productName, int quantity, decimal price)
{
ProductName = productName;
Quantity = quantity;
Price = price;
}
public decimal GetTotalPrice() => Quantity * Price;
}
- The
OrderItem
class is a Value Object because it does not have a unique identity; it’s a part of theOrder
entity. - It has properties for
ProductName
,Quantity
, andPrice
. - The
GetTotalPrice
method calculates the total cost of the item based on its quantity and price.
3️⃣Define the Order Status Enum
The OrderStatus
enum will represent the various states an order can be in. This enum helps us model the business rules for order progression.
public enum OrderStatus
{
Created,
Shipped,
Delivered
}
- The
OrderStatus
enum defines the possible states of an order. - It’s used to track and transition the order’s state, helping to enforce business rules about order processing.
4️⃣Define the Repository — OrderRepository
In DDD, Repositories provide a way to access aggregates (like Order
) from a data store. They abstract away the details of data storage, allowing the domain logic to focus on business rules rather than data access.
public interface IOrderRepository
{
Order GetById(int orderId);
void Save(Order order);
}
public class OrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new List<Order>();
public Order GetById(int orderId)
{
return _orders.FirstOrDefault(o => o.OrderId == orderId);
}
public void Save(Order order)
{
var existingOrder = _orders.FirstOrDefault(o => o.OrderId == order.OrderId);
if (existingOrder != null)
{
_orders.Remove(existingOrder); // Update existing order
}
_orders.Add(order); // Save the order
}
}
- The
IOrderRepository
interface defines the contract for retrieving and saving orders. - The
OrderRepository
class provides the implementation of the repository using an in-memory list for simplicity. - The
GetById
method retrieves an order by itsOrderId
, and theSave
method adds or updates the order in the repository.
5️⃣Implement the Order Service — OrderService
The OrderService
acts as a domain service that contains business logic involving one or more domain entities. It coordinates actions such as placing orders or updating statuses.
public class OrderService
{
private readonly IOrderRepository _orderRepository;
public OrderService(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public void PlaceOrder(int orderId, string customerName)
{
var order = new Order(orderId, customerName);
_orderRepository.Save(order);
}
public void AddItemToOrder(int orderId, string productName, int quantity, decimal price)
{
var order = _orderRepository.GetById(orderId);
if (order == null)
{
throw new Exception("Order not found.");
}
var orderItem = new OrderItem(productName, quantity, price);
order.AddItem(orderItem);
_orderRepository.Save(order);
}
public void ShipOrder(int orderId)
{
var order = _orderRepository.GetById(orderId);
if (order == null)
{
throw new Exception("Order not found.");
}
order.MarkShipped();
_orderRepository.Save(order);
}
public void DeliverOrder(int orderId)
{
var order = _orderRepository.GetById(orderId);
if (order == null)
{
throw new Exception("Order not found.");
}
order.MarkDelivered();
_orderRepository.Save(order);
}
}
The OrderService
class contains methods to handle business logic:
PlaceOrder
creates a newOrder
and saves it using the repository.AddItemToOrder
adds an item to an existing order.ShipOrder
andDeliverOrder
change the order’s status and save the changes to the repository.
The OrderService
acts as a facade to encapsulate complex domain logic and ensure that interactions with the Order
entity are consistent.
6️⃣Use the Application — Main Program
Now, let’s use the OrderService
to simulate placing an order, adding items, and updating its status.
class Program
{
static void Main()
{
var orderRepository = new OrderRepository();
var orderService = new OrderService(orderRepository);
// Place a new order
orderService.PlaceOrder(1, "John Doe");
// Add items to the order
orderService.AddItemToOrder(1, "Laptop", 1, 1000.00m);
orderService.AddItemToOrder(1, "Mouse", 2, 25.00m);
// Ship the order
orderService.ShipOrder(1);
// Deliver the order
orderService.DeliverOrder(1);
// Display order details
var order = orderRepository.GetById(1);
Console.WriteLine($"Order {order.OrderId} for {order.CustomerName} is {order.Status}");
}
}
- The program simulates an order lifecycle.
- A new order is placed with
PlaceOrder
. - Items are added to the order using
AddItemToOrder
. - The order is marked as Shipped and then Delivered.
- The program retrieves the order from the repository and displays its final status.
Microservices Pattern — Building Scalable and Maintainable .NET Microservices
Microservices architecture decomposes an application into smaller, independently deployable services, each responsible for a specific business functionality. This pattern enables flexibility, scalability, and resilience by allowing services to evolve independently and scale based on demand. In .NET, microservices can be built using technologies such as ASP.NET Core, Docker, and Kubernetes, providing a robust framework for creating distributed systems. By adhering to best practices such as service autonomy, decentralized data management, and inter-service communication, developers can build maintainable, scalable, and high-performing microservices-based applications.
- Scalability — Each service can scale independently.
- Resilience — Failure in one service does not impact the entire system.
- Technology Diversity — Different services can use different technologies.
Common Usages
- Cloud Applications — Scaling individual components dynamically.
- E-Commerce Platforms — Managing inventory, orders, and payments separately.
- Real-Time Systems — Handling data streaming and processing.
Supported SOLID Principles
- Single Responsibility Principle — Each microservice has a focused responsibility.
- Open/Closed Principle — New services can be introduced without affecting existing ones.
💡Implementing Microservices for an E-Commerce Application
In this walkthrough, we will split the e-commerce application into two main microservices:
- Order Service — Handles order creation and management.
- Product Service — Manages product information.
We’ll use ASP.NET Core for building the microservices and demonstrate how they communicate using HTTP APIs.
1️⃣Define the Product Service
The Product Service manages product information like product details and stock levels.
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
private readonly List<Product> _products = new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 1000.00m, StockQuantity = 50 },
new Product { Id = 2, Name = "Phone", Price = 500.00m, StockQuantity = 200 }
};
[HttpGet("{id}")]
public ActionResult<Product> GetProduct(int id)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product == null)
{
return NotFound();
}
return Ok(product);
}
}
- The
Product
class represents the Product Entity, with properties forId
,Name
,Price
, andStockQuantity
. - The
ProductController
exposes a simple HTTP API for retrieving product details. - The
GetProduct
method takes a productid
, searches for the product in an in-memory list, and returns it if found.
2️⃣Define the Order Service
The Order Service handles order creation and uses the Product Service to check product availability.
public class Order
{
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public string Status { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly IHttpClientFactory _httpClientFactory;
public OrderController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
[HttpPost]
public async Task<ActionResult<Order>> CreateOrder([FromBody] Order order)
{
// Call Product Service to check product availability
var client = _httpClientFactory.CreateClient("ProductService");
var response = await client.GetAsync($"api/product/{order.ProductId}");
if (!response.IsSuccessStatusCode)
{
return BadRequest("Product not available.");
}
// Assuming product exists, create the order
order.Status = "Created";
return CreatedAtAction(nameof(CreateOrder), new { id = order.OrderId }, order);
}
}
- The
Order
class represents the Order Entity, with properties forOrderId
,ProductId
,Quantity
, andStatus
. - The
OrderController
exposes an HTTP API for creating orders. - The
CreateOrder
method calls the Product Service using an HTTP client to check if the product is available before creating an order. - If the product is available, the order is created with a status of “Created”; otherwise, a
BadRequest
response is returned.
3️⃣Configuring Inter-Service Communication
To make the Order Service communicate with the Product Service, we use HttpClient and configure it in Startup.cs.
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddHttpClient("ProductService", client =>
{
client.BaseAddress = new Uri("http://localhost:5001"); // URL of the Product Service
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
- The
AddHttpClient
method registers a named HTTP client (ProductService
) for communicating with the Product Service. - The
BaseAddress
is configured to theProductService
URL, which is assumed to be running on port5001
. - This setup allows the Order Service to make HTTP calls to the Product Service to check product availability.
4️⃣Running the Microservices
Assuming you have two separate projects (one for the Product Service and one for the Order Service), you would need to run them on different ports (e.g., Product Service on 5001
and Order Service on 5002
). You can use Docker to containerize and run these microservices independently, or you can run them locally in Visual Studio.
5️⃣Testing the Microservices
You can now test the communication between the two services using tools like Postman or curl:
Create a product (via Product Service):
- URL:
http://localhost:5001/api/product/1
- Method:
GET
- Response: JSON representation of the product.
Create an order (via Order Service):
- URL:
http://localhost:5002/api/order
- Method:
POST
- Body:
{
"ProductId": 1,
"Quantity": 2
}
- Response: Created order with a status of “Created”.
6️⃣Scaling the Microservices
Once your services are up and running, you can scale them independently. For example, if the Order Service receives more traffic, you can scale it separately without affecting the Product Service.
In a production environment, you can deploy these services using Kubernetes or Docker Swarm, and they can communicate over a service mesh (e.g., Istio), load balancer, or API Gateway.
Once your services are up and running, you can scale them independently. For example, if the Order Service receives more traffic, you can scale it separately without affecting the Product Service.
In a production environment, you can deploy these services using Kubernetes or Docker Swarm, and they can communicate over a service mesh (e.g., Istio), load balancer, or API Gateway.
Understanding .NET architectural patterns helps developers build scalable, maintainable, and efficient applications. Whether using MVC for structured UI, CQRS for performance optimization, or Microservices for scalability, each pattern has its strengths. By applying the right pattern for the right scenario, developers can design software that is both robust and future-proof.