Sunday, October 13, 2024

Building a Microservices Ecosystem with .NET 8.0: Orders, Products, and Customers

Table of Contents

  1. Introduction
  2. Architecture Overview
  3. Setting Up the Microservices
  4. Implementing the Microservices
    4.1 Product Service
    4.2 Customer Service
    4.3 Order Service
  5. Inter-Service Communication
  6. API Gateway
  7. Running the Ecosystem
  8. Microservices Best Practice
  9. Conclusion

1. Introduction

Microservices architecture allows us to build complex systems by breaking them down into smaller, manageable services. In this article, we'll create three microservices that work together to manage an e-commerce platform:

  • Product Service: Manages product information and inventory
  • Customer Service: Handles customer data and authentication
  • Order Service: Processes and manages orders

We'll use .NET 8.0 to build these services and demonstrate how they can communicate with each other to fulfill business operations.

2. Architecture Overview

Here's a high-level overview of our microservices ecosystem:

[API Gateway] | |--- [Product Service] | |--- [Customer Service] | |--- [Order Service]
  • The API Gateway will route requests to the appropriate service.
  • Each service will have its own database.
  • Services will communicate with each other using HTTP/REST.

3. Setting Up the Microservices

Let's start by creating three separate projects for our microservices:

bash
dotnet new webapi -n ProductService dotnet new webapi -n CustomerService dotnet new webapi -n OrderService

For each project, add the following NuGet packages:

bash
dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection dotnet add package MediatR.Extensions.Microsoft.DependencyInjection dotnet add package Microsoft.Extensions.Http

4. Implementing the Microservices

4.1 Product Service

Let's implement the Product Service:

csharp
// ProductService/Models/Product.cs public class Product { public Guid Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } } // ProductService/Data/ProductDbContext.cs public class ProductDbContext : DbContext { public ProductDbContext(DbContextOptions<ProductDbContext> options) : base(options) { } public DbSet<Product> Products { get; set; } } // ProductService/Controllers/ProductsController.cs [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly ProductDbContext _context; public ProductsController(ProductDbContext context) { _context = context; } [HttpGet("{id}")] public async Task<ActionResult<Product>> GetProduct(Guid id) { var product = await _context.Products.FindAsync(id); if (product == null) return NotFound(); return product; } [HttpPost] public async Task<ActionResult<Product>> CreateProduct(Product product) { _context.Products.Add(product); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product); } [HttpPut("{id}")] public async Task<IActionResult> UpdateStock(Guid id, int quantity) { var product = await _context.Products.FindAsync(id); if (product == null) return NotFound(); product.StockQuantity = quantity; await _context.SaveChangesAsync(); return NoContent(); } }

4.2 Customer Service

Now, let's implement the Customer Service:

csharp
// CustomerService/Models/Customer.cs public class Customer { public Guid Id { get; set; } public string Name { get; set; } public string Email { get; set; } } // CustomerService/Data/CustomerDbContext.cs public class CustomerDbContext : DbContext { public CustomerDbContext(DbContextOptions<CustomerDbContext> options) : base(options) { } public DbSet<Customer> Customers { get; set; } } // CustomerService/Controllers/CustomersController.cs [ApiController] [Route("api/[controller]")] public class CustomersController : ControllerBase { private readonly CustomerDbContext _context; public CustomersController(CustomerDbContext context) { _context = context; } [HttpGet("{id}")] public async Task<ActionResult<Customer>> GetCustomer(Guid id) { var customer = await _context.Customers.FindAsync(id); if (customer == null) return NotFound(); return customer; } [HttpPost] public async Task<ActionResult<Customer>> CreateCustomer(Customer customer) { _context.Customers.Add(customer); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetCustomer), new { id = customer.Id }, customer); } }

4.3 Order Service

Finally, let's implement the Order Service, which will interact with both Product and Customer services:

csharp
// OrderService/Models/Order.cs public class Order { public Guid Id { get; set; } public Guid CustomerId { get; set; } public List<OrderItem> Items { get; set; } public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } } public class OrderItem { public Guid ProductId { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } } // OrderService/Data/OrderDbContext.cs public class OrderDbContext : DbContext { public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { } public DbSet<Order> Orders { get; set; } } // OrderService/Services/ProductService.cs public class ProductService { private readonly HttpClient _httpClient; public ProductService(HttpClient httpClient) { _httpClient = httpClient; } public async Task<bool> UpdateStock(Guid productId, int quantity) { var response = await _httpClient.PutAsJsonAsync($"api/products/{productId}", quantity); return response.IsSuccessStatusCode; } } // OrderService/Services/CustomerService.cs public class CustomerService { private readonly HttpClient _httpClient; public CustomerService(HttpClient httpClient) { _httpClient = httpClient; } public async Task<bool> CustomerExists(Guid customerId) { var response = await _httpClient.GetAsync($"api/customers/{customerId}"); return response.IsSuccessStatusCode; } } // OrderService/Controllers/OrdersController.cs [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly OrderDbContext _context; private readonly ProductService _productService; private readonly CustomerService _customerService; public OrdersController(OrderDbContext context, ProductService productService, CustomerService customerService) { _context = context; _productService = productService; _customerService = customerService; } [HttpPost] public async Task<ActionResult<Order>> CreateOrder(Order order) { // Check if customer exists if (!await _customerService.CustomerExists(order.CustomerId)) return BadRequest("Invalid customer"); // Update product stock foreach (var item in order.Items) { if (!await _productService.UpdateStock(item.ProductId, -item.Quantity)) return BadRequest($"Failed to update stock for product {item.ProductId}"); } order.OrderDate = DateTime.UtcNow; _context.Orders.Add(order); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); } [HttpGet("{id}")] public async Task<ActionResult<Order>> GetOrder(Guid id) { var order = await _context.Orders.FindAsync(id); if (order == null) return NotFound(); return order; } }

5. Inter-Service Communication

As you can see in the Order Service, we're using HttpClient to communicate with the Product and Customer services. This is a simple form of inter-service communication. In a production environment, you might want to consider more robust solutions like service discovery, message queues, or event-driven architectures.

6. API Gateway

To simplify client interactions with our microservices, we can implement an API Gateway. Here's a simple example using YARP (Yet Another Reverse Proxy):

bash
dotnet new web -n ApiGateway cd ApiGateway dotnet add package Microsoft.ReverseProxy

Then, update the Program.cs file:

csharp
// ApiGateway/Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); var app = builder.Build(); app.MapReverseProxy(); app.Run();

And add the following to your appsettings.json:

json
{ "ReverseProxy": { "Routes": { "products": { "ClusterId": "products", "Match": { "Path": "/products/{**catch-all}" }, "Transforms": [ { "PathPattern": "api/products/{**catch-all}" } ] }, "customers": { "ClusterId": "customers", "Match": { "Path": "/customers/{**catch-all}" }, "Transforms": [ { "PathPattern": "api/customers/{**catch-all}" } ] }, "orders": { "ClusterId": "orders", "Match": { "Path": "/orders/{**catch-all}" }, "Transforms": [ { "PathPattern": "api/orders/{**catch-all}" } ] } }, "Clusters": { "products": { "Destinations": { "destination1": { "Address": "https://localhost:5001" } } }, "customers": { "Destinations": { "destination1": { "Address": "https://localhost:5002" } } }, "orders": { "Destinations": { "destination1": { "Address": "https://localhost:5003" } } } } } }

7. Running the Ecosystem

To run our microservices ecosystem:

  1. Start each microservice (Product, Customer, Order) on different ports.
  2. Start the API Gateway.
  3. Use the API Gateway URL to interact with the services.

For example, to create an order:

http
POST https://localhost:5000/orders Content-Type: application/json { "customerId": "00000000-0000-0000-0000-000000000001", "items": [ { "productId": "00000000-0000-0000-0000-000000000001", "quantity": 2, "unitPrice": 10.99 } ], "totalAmount": 21.98 }

This request will:

  1. Check if the customer exists via the Customer Service
  2. Update the product stock via the Product Service
  3. Create the order in the Order Service

8. Microservices Best Practices

When developing a microservices architecture, it's crucial to follow best practices to ensure your system is robust, scalable, and maintainable. Here are some key best practices to consider:

8.1 Design Principles

  1. Single Responsibility Principle: Each microservice should have a single, well-defined responsibility. In our example, we have separate services for products, customers, and orders.
  2. Database per Service: Each microservice should have its own database. This ensures loose coupling and allows each service to choose the most appropriate database technology.
  3. API First Design: Design your service APIs before implementing the services. This helps in clearly defining the service boundaries and interactions.
  4. Stateless Services: Design your services to be stateless. This makes them easier to scale horizontally.

8.2 Development Practices

  1. Use of Domain-Driven Design (DDD): Apply DDD principles to model your microservices around business domains.
  2. Continuous Integration and Continuous Deployment (CI/CD): Implement robust CI/CD pipelines for each microservice to automate testing and deployment.
  3. Containerization: Use containerization technologies like Docker to ensure consistency across different environments and facilitate easy deployment.
  4. Automated Testing: Implement comprehensive unit tests, integration tests, and contract tests for each microservice.

8.3 Operational Practices

  1. Centralized Logging: Implement a centralized logging system to aggregate logs from all microservices for easier debugging and monitoring.
  2. Distributed Tracing: Use distributed tracing to track requests as they flow through your microservices ecosystem.
  3. Health Checks: Implement health check endpoints in each service to facilitate monitoring and auto-healing.
  4. Circuit Breaker Pattern: Implement circuit breakers to prevent cascading failures when a service is down.

8.4 Communication Practices

  1. API Gateway: Use an API gateway to handle cross-cutting concerns like authentication, SSL termination, and routing.
  2. Service Discovery: Implement service discovery to allow services to find and communicate with each other dynamically.
  3. Event-Driven Architecture: Consider using an event-driven architecture for loose coupling and better scalability.
  4. Asynchronous Communication: Use asynchronous communication where possible to improve responsiveness and scalability.

8.5 Data Management Practices

  1. Data Consistency: Use patterns like Saga for managing data consistency across services in distributed transactions.
  2. CQRS Pattern: Consider using the Command Query Responsibility Segregation (CQRS) pattern for complex domains with different read and write operations.
  3. API Versioning: Version your APIs to allow for backward compatibility as services evolve.
  4. Data Backup and Recovery: Implement robust data backup and recovery processes for each service's database.

Implementation Example: Health Checks

Let's implement health checks in our services as an example of applying these best practices. We'll add health checks to the Order Service:

csharp
// OrderService/Program.cs var builder = WebApplication.CreateBuilder(args); // ... other configurations ... builder.Services.AddHealthChecks() .AddDbContextCheck<OrderDbContext>() .AddUrlGroup(new Uri("https://localhost:5001/health"), name: "product-service") .AddUrlGroup(new Uri("https://localhost:5002/health"), name: "customer-service"); var app = builder.Build(); // ... other middleware ... app.MapHealthChecks("/health"); app.Run();

This adds a health check endpoint that checks:

  • The Order Service's database connection
  • The availability of the Product Service
  • The availability of the Customer Service

You would then add similar health check endpoints to the Product and Customer services.

By implementing these best practices, you can create a more robust, scalable, and maintainable microservices architecture. Remember, not all practices may be necessary or applicable to every project. Always consider your specific requirements and constraints when deciding which practices to adopt.

9. Conclusion

In this article, we've created a microservices ecosystem using .NET 8.0, demonstrating how different services can work together to create a complex e-commerce backend. We've covered:

  • Creating individual microservices for Products, Customers, and Orders
  • Implementing inter-service communication
  • Setting up an API Gateway to simplify client interactions

This architecture allows for independent scaling and deployment of services, making it easier to manage and evolve complex systems over time.

Remember, this is a simplified example. In a production environment, you'd need to consider additional factors such as:

  • Authentication and authorization
  • Resilience patterns (e.g., Circuit Breaker, Retry)
  • Monitoring and logging
  • Data consistency across services
  • Testing strategies for microservices

As you continue to develop your microservices architecture, keep these considerations in mind to build a robust, scalable, and maintainable system. 

In addition to the basic implementation, we've now covered key best practices for developing microservices. By following these practices, you can create a more robust, scalable, and maintainable microservices architecture. Remember to continuously evaluate and refine your approach as your system grows and evolves.