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 modelProductTableEntity– Azure Table Storage entityProductDto– 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.
