Designing Microservices: A Hands-On CloudOrder API Blueprint (Part 2)

Ankit Sharma
0


Part 2 – Building CloudOrder APIs Locally

In Part 1, we designed CloudOrder on paper: the business scenario, domain boundaries, storage model, and API contracts. In this post, we turn that blueprint into running services.

The goal here is not to build a production-ready system yet. The goal is to validate architectural decisions early by implementing the APIs, running them locally, and verifying how they behave with Azure Table Storage.

By the end of this post:

  • CloudOrder APIs run locally using .NET 10
  • Data is persisted using Azure Table Storage (Azurite)
  • Repositories isolate storage concerns
  • Controllers remain clean and storage-agnostic

Full source code is available on GitHub. This post focuses on structure, decisions, and key snippets, not repetitive boilerplate.



What We’re Building

CloudOrder v1 consists of three independent backend services:

  • Product API – product catalog and stock management
  • Customer API – customer profile and address
  • Order API – order creation and order status tracking

Each service:

  • owns its own data
  • exposes a small REST API
  • uses Azure Table Storage for persistence
  • is designed to evolve independently

There is no authentication, messaging, or UI yet. This is intentional.



Solution Structure

The solution is organized by bounded context, not by technical layers:


CloudOrder/
├── CloudOrder.Contracts          (DTOs and shared enums)
├── CloudOrder.Product.Api        (Product service)
├── CloudOrder.Customer.Api       (Customer service)
├── CloudOrder.Order.Api          (Order service)
└── CloudOrder.slnx

Each service contains its own:

  • domain models
  • storage entities
  • repositories
  • controllers

This structure keeps service boundaries clear and avoids accidental coupling.



DTOs, Domain Models, and Storage Entities

CloudOrder uses three distinct model types, each with a clear responsibility:

  • DTOs – define API contracts (in CloudOrder.Contracts)
  • Domain models – represent business concepts
  • Table entities – define how data is stored

For example, the Product service contains:

  • Product – domain model
  • ProductTableEntity – Azure Table Storage entity
  • ProductDto – API response contract

These models are connected using explicit mappers. This allows the system to evolve (storage migration, API changes) without forcing large refactors.



Repository Pattern

To keep controllers independent of persistence details, each service defines a repository interface.

Repositories:

  • return domain models, not storage entities
  • hide Azure Table Storage specifics
  • enable future migration (Table Storage → Cosmos DB)

Example repository interface (Product service):


public interface IProductRepository
{
    Task<Product?> GetByIdAsync(string productId);
    Task<IReadOnlyList<Product>> GetAllAsync();
    Task CreateAsync(Product product);
    Task UpdateStockAsync(string productId, int stockLevel);
}

The current implementation uses Azure Table Storage. A future Cosmos DB implementation can replace it without changing controllers or API contracts.



Controllers: Thin and Focused

Controllers are intentionally thin. Their responsibilities are limited to:

  • accepting HTTP requests
  • mapping DTOs to domain models
  • calling repositories
  • returning HTTP responses

They do not know about:

  • PartitionKey / RowKey
  • ETags
  • Azure SDK types

Example: Product creation endpoint (simplified):


[HttpPost]
public async Task<IActionResult> Create(CreateProductRequest request)
{
    var product = new Product(
        Guid.NewGuid(),
        request.Name,
        request.ImageUrl,
        request.Price,
        request.StockLevel
    );

    await _repository.CreateAsync(product);

    return CreatedAtAction(
        nameof(GetById),
        new { productId = product.Id },
        ProductMapper.ToDto(product)
    );
}


Azure Table Storage Modeling

Azure Table Storage is modeled intentionally around access patterns:

Table PartitionKey RowKey
Products PRODUCT productId
Customers CUSTOMER customerId
Orders customerId orderId

This design:

  • keeps queries efficient
  • avoids cross-partition scans
  • directly influences API shape


Running Locally with Azurite

All services are run locally using Azurite.


azurite

Each API is started using:


dotnet run

Requests are tested using the .http files included with the ASP.NET Core Web API project (via Visual Studio / VS Code HTTP client), and data is verified using Azure Storage Explorer.

This provides fast feedback and validates the full request → storage → response flow.



Design Decisions Worth Calling Out

  • Controllers depend on repository interfaces, not storage SDKs
  • Repositories return domain models, not storage entities
  • Order service does not synchronously call Customer service
  • Product validation is performed during order creation; customer identity is treated as a reference

These decisions keep services loosely coupled and ready to evolve.



Putting It All Together: A Concrete Example

To avoid repeating similar code across all services, this section walks through one complete vertical slice from request to storage using the Product API.

The same patterns are applied consistently across Customer and Order services.


1. API Contract (DTO)

DTOs define what the API accepts and returns. They live in CloudOrder.Contracts and contain no business or persistence logic.


public record CreateProductRequest(
    string Name,
    string ImageUrl,
    decimal Price,
    int StockLevel
);

public record ProductDto(
    Guid Id,
    string Name,
    string ImageUrl,
    decimal Price,
    int StockLevel
);

These types represent the external API contract and are intentionally stable.


2. Domain Model

Domain models represent business concepts inside a service. They are independent of API and storage concerns.


public class Product
{
    public Guid Id { get; }
    public string Name { get; }
    public string ImageUrl { get; }
    public decimal Price { get; }
    public int StockLevel { get; }

    public Product(Guid id, string name, string imageUrl, decimal price, int stockLevel)
    {
        Id = id;
        Name = name;
        ImageUrl = imageUrl;
        Price = price;
        StockLevel = stockLevel;
    }
}

The domain model expresses intent and business meaning, not persistence mechanics.


3. Storage Entity (Azure Table Storage)

Storage entities define how data is persisted. These types are isolated inside each service and never exposed outside repositories.


public class ProductTableEntity : ITableEntity
{
    public string PartitionKey { get; set; } = "PRODUCT";
    public string RowKey { get; set; } = default!;
    public string Name { get; set; } = default!;
    public string ImageUrl { get; set; } = default!;
    public decimal Price { get; set; }
    public int StockLevel { get; set; }

    public DateTimeOffset? Timestamp { get; set; }
    public ETag ETag { get; set; }
}

PartitionKey and RowKey encode query patterns and are hidden from higher layers.


4. Mapper

Mappers connect DTOs, domain models, and storage entities explicitly. This keeps transformations visible and avoids magic.


public static class ProductMapper
{
    public static ProductTableEntity ToTableEntity(Product product) =>
        new()
        {
            RowKey = product.Id.ToString(),
            Name = product.Name,
            ImageUrl = product.ImageUrl,
            Price = product.Price,
            StockLevel = product.StockLevel
        };

    public static Product ToDomain(ProductTableEntity entity) =>
        new(
            Guid.Parse(entity.RowKey),
            entity.Name,
            entity.ImageUrl,
            entity.Price,
            entity.StockLevel
        );

    public static ProductDto ToDto(Product product) =>
        new(
            product.Id,
            product.Name,
            product.ImageUrl,
            product.Price,
            product.StockLevel
        );
}

This mapping layer is what allows storage or API shapes to change independently.


5. Repository

Repositories isolate persistence logic and expose domain-focused operations. Controllers depend on interfaces, not storage SDKs.


public interface IProductRepository
{
    Task GetByIdAsync(string productId);
    Task CreateAsync(Product product);
}

public class TableProductRepository : IProductRepository
{
    private readonly TableClient _tableClient;

    public TableProductRepository(TableClient tableClient)
    {
        _tableClient = tableClient;
    }

    public async Task GetByIdAsync(string productId)
    {
        var result = await _tableClient.GetEntityIfExistsAsync(
            "PRODUCT", productId);

        return result.HasValue
            ? ProductMapper.ToDomain(result.Value)
            : null;
    }

    public async Task CreateAsync(Product product)
    {
        var entity = ProductMapper.ToTableEntity(product);
        await _tableClient.AddEntityAsync(entity);
    }
}

If Azure Table Storage is replaced later, only this implementation changes.


6. Controller

Controllers orchestrate requests without knowing how data is stored.


[HttpPost]
public async Task<IActionResult> Create(CreateProductRequest request)
{
    var product = new Product(
        Guid.NewGuid(),
        request.Name,
        request.ImageUrl,
        request.Price,
        request.StockLevel
    );

    await _repository.CreateAsync(product);

    return CreatedAtAction(
        nameof(GetById),
        new { productId = product.Id },
        ProductMapper.ToDto(product)
    );
}

The same pattern is applied consistently across Customer and Order services.

Customer and Order APIs follow the same structure, with differences only in domain rules and storage access patterns.



What We Learned

  • Storage modeling directly impacts API design
  • Small, focused APIs are easier to reason about
  • Separating domain, storage, and contracts pays off early
  • Designing for evolution does not require complex infrastructure on day one


What’s Next

With CloudOrder APIs running locally, the next step is to host the same codebase using different Azure compute options:

  • App Service
  • Container Apps
  • AKS
  • Azure Functions

Same APIs. Same behavior. Different operational trade-offs.

That’s where cloud architecture decisions really start to matter.

Post a Comment

0 Comments

Post a Comment (0)
3/related/default