This repository contains a small microservices learning project built with .NET, ASP.NET Core, PostgreSQL, EF Core, Docker Compose, and HTTP service-to-service communication.
The project demonstrates two independent services communicating synchronously over HTTP:
Catalog Service -> Pricing Service
The main goal is to understand the trade-offs and failure modes of synchronous microservice communication.
Catalog Service owns product catalog metadata.
It stores:
- product name
- description
- SKU
- active/inactive status
Catalog Service does not store prices.
When a product detail is requested, Catalog Service calls Pricing Service over HTTP to retrieve the current price.
Pricing Service owns product price data.
It stores:
- product ID
- amount
- currency
Pricing Service does not know anything about product metadata such as name, description, or SKU.
HTTP request
+------------------+ +------------------+
| Catalog Service | -----> | Pricing Service |
| | | |
| Catalog DB | | Pricing DB |
+------------------+ +------------------+
Each service has its own database:
Catalog Service -> catalog_service database
Pricing Service -> pricing_service database
Catalog Service communicates with Pricing Service only through HTTP.
It does not read Pricing Service database directly.
This project demonstrates:
- ASP.NET Core Web API with Controllers
- PostgreSQL database per service
- EF Core migrations
- clean project structure
- domain/application/infrastructure separation
- DTO-based API contracts
- API versioning through
/api/v1/... - typed
HttpClient HttpClientFactory- timeout configuration
- retry behavior
- fallback behavior
- health checks
- Problem Details error responses
- Swagger/OpenAPI documentation
- unit tests
- integration tests
- Testcontainers with PostgreSQL
- code coverage collection
- HTML coverage reports
- coverage threshold check
- GitHub Actions CI pipeline
- Docker Compose smoke test
- Docker image vulnerability scanning
- Docker image SBOM generation
- non-root Docker containers
- .NET 10
- ASP.NET Core
- C#
- PostgreSQL
- EF Core
- Npgsql
- FluentValidation
- Docker Compose
- Swagger/OpenAPI
- Microsoft.Extensions.Http.Resilience
- xUnit
- WebApplicationFactory
- Testcontainers
- Coverlet
- ReportGenerator
- GitHub Actions
- Anchore Grype
- Anchore Syft / SBOM
src/
CatalogService/
CatalogService.Api/
CatalogService.Application/
CatalogService.Domain/
CatalogService.Infrastructure/
PricingService/
PricingService.Api/
PricingService.Application/
PricingService.Domain/
PricingService.Infrastructure/
tests/
CatalogService.Tests.Unit/
CatalogService.Tests.Integration/
PricingService.Tests.Unit/
PricingService.Tests.Integration/
ServiceCommunication.Tests.Integration/
scripts/
check-coverage.ps1
docs/
adr/
.github/
workflows/
ci.yml
dependabot.yml
When running locally through Docker Compose:
| Service | URL |
|---|---|
| Catalog Service API | http://localhost:5101 |
| Pricing Service API | http://localhost:5102 |
| Catalog PostgreSQL | localhost:5433 |
| Pricing PostgreSQL | localhost:5434 |
Inside Docker Compose network:
| Service | Internal URL |
|---|---|
| Catalog Service API | http://catalog-service-api:8080 |
| Pricing Service API | http://pricing-service-api:8080 |
| Catalog PostgreSQL | catalog-service-db:5432 |
| Pricing PostgreSQL | pricing-service-db:5432 |
Catalog Service Swagger:
http://localhost:5101/swagger
Pricing Service Swagger:
http://localhost:5102/swagger
OpenAPI JSON:
http://localhost:5101/openapi/v1.json
http://localhost:5102/openapi/v1.json
Catalog Service:
http://localhost:5101/health
http://localhost:5101/health/live
http://localhost:5101/health/ready
Pricing Service:
http://localhost:5102/health
http://localhost:5102/health/live
http://localhost:5102/health/ready
/health/ready checks whether the service can access its PostgreSQL database.
Important note:
A healthy database connection does not automatically mean that EF Core migrations were applied.
If Docker volumes were deleted, the database can be reachable but tables may still be missing until migrations are applied.
Required tools:
- .NET 10 SDK
- Docker Desktop
- Git
Optional tools:
- DBeaver
- Postman
- curl
- PowerShell 7
Start databases first:
docker compose up -d catalog-service-db pricing-service-dbApply Catalog Service migration:
dotnet ef database update \
--project src/CatalogService/CatalogService.Infrastructure/CatalogService.Infrastructure.csproj \
--startup-project src/CatalogService/CatalogService.Api/CatalogService.Api.csproj \
--context CatalogDbContextApply Pricing Service migration:
dotnet ef database update \
--project src/PricingService/PricingService.Infrastructure/PricingService.Infrastructure.csproj \
--startup-project src/PricingService/PricingService.Api/PricingService.Api.csproj \
--context PricingDbContextStart the full system:
docker compose up --buildThe APIs should be available at:
http://localhost:5101
http://localhost:5102
This project does not automatically apply EF Core migrations on application startup.
Database schema changes are applied manually through:
dotnet ef database updateThis is intentional.
Automatic migrations on startup can be useful in simple local development scenarios, but in real environments they are a production trade-off and should be handled carefully.
If you run:
docker compose down -vDocker deletes PostgreSQL volumes.
That also deletes database tables.
After that, run migrations again before testing API business endpoints.
The Docker Compose stack includes health checks for:
- Catalog PostgreSQL
- Pricing PostgreSQL
- Catalog Service API
- Pricing Service API
API containers are checked through:
/health/ready
Run:
docker compose psExpected result:
catalog-service-api healthy
pricing-service-api healthy
catalog-service-db healthy
pricing-service-db healthy
Docker Compose smoke test is also executed in CI.
API containers run as a non-root user.
The Dockerfiles use:
USER appThe APIs listen on port 8080 inside the container.
Docker Compose maps the internal ports to host ports:
Catalog Service API: 5101 -> 8080
Pricing Service API: 5102 -> 8080
This is a basic container hardening step.
The application process does not run as root inside the container.
Base URL:
http://localhost:5101
Endpoints:
GET /api/v1/catalog-products
GET /api/v1/catalog-products/{id}
POST /api/v1/catalog-products
PUT /api/v1/catalog-products/{id}
DELETE /api/v1/catalog-products/{id}The Catalog product detail endpoint calls Pricing Service over HTTP:
GET /api/v1/catalog-products/{id}Example response with price:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Mechanical Keyboard",
"description": "Compact keyboard for developers",
"sku": "KEYBOARD-001",
"isActive": true,
"price": 1299.99,
"currency": "CZK",
"priceStatus": "Available",
"createdAt": "2026-06-18T10:00:00+00:00",
"updatedAt": "2026-06-18T10:00:00+00:00"
}Possible priceStatus values:
Available
NotSet
Unavailable
| Status | Meaning |
|---|---|
Available |
Pricing Service returned a price. |
NotSet |
Pricing Service is available, but no price exists for the product. |
Unavailable |
Pricing Service is unavailable, timed out, or failed. |
Base URL:
http://localhost:5102
Endpoints:
GET /api/v1/prices/{productId}
POST /api/v1/prices
PUT /api/v1/prices/{productId}Example create price request:
{
"productId": "11111111-1111-1111-1111-111111111111",
"amount": 1299.99,
"currency": "CZK"
}Example response:
{
"productId": "11111111-1111-1111-1111-111111111111",
"amount": 1299.99,
"currency": "CZK",
"createdAt": "2026-06-18T10:00:00+00:00",
"updatedAt": "2026-06-18T10:00:00+00:00"
}Create a Catalog product:
curl -i -X POST http://localhost:5101/api/v1/catalog-products \
-H "Content-Type: application/json" \
-d '{
"name": "Mechanical Keyboard",
"description": "Compact keyboard for developers",
"sku": "KEYBOARD-001"
}'Copy the returned product id.
Get product detail before price is set:
curl -i http://localhost:5101/api/v1/catalog-products/{catalogProductId}Expected price information:
{
"price": null,
"currency": null,
"priceStatus": "NotSet"
}Create price in Pricing Service:
curl -i -X POST http://localhost:5102/api/v1/prices \
-H "Content-Type: application/json" \
-d '{
"productId": "{catalogProductId}",
"amount": 1299.99,
"currency": "CZK"
}'Get product detail again:
curl -i http://localhost:5101/api/v1/catalog-products/{catalogProductId}Expected price information:
{
"price": 1299.99,
"currency": "CZK",
"priceStatus": "Available"
}Catalog Service handles Pricing Service failures gracefully.
If Pricing Service is unavailable, Catalog Service still returns product metadata:
{
"price": null,
"currency": null,
"priceStatus": "Unavailable"
}This demonstrates partial failure handling in synchronous microservice communication.
Catalog Service uses a typed HTTP client for Pricing Service.
Configuration example:
{
"PricingService": {
"BaseUrl": "http://localhost:5102",
"TimeoutSeconds": 3,
"RetryCount": 2,
"RetryDelayMilliseconds": 200
}
}In Docker Compose, Catalog Service calls Pricing Service through the internal Docker network:
PricingService__BaseUrl: http://pricing-service-api:8080Inside a Docker container, localhost would point to the Catalog container itself, not to Pricing Service.
Both services use global exception handling and return Problem Details responses.
Examples:
| Scenario | Status |
|---|---|
| Validation error | 400 Bad Request |
| Duplicate SKU | 409 Conflict |
| Duplicate product price | 409 Conflict |
| Not found | 404 Not Found |
| Unexpected error | 500 Internal Server Error |
Problem Details responses use:
application/problem+json
Run all tests:
dotnet test TwoServicesHttp.slnxRun unit tests only:
dotnet test tests/CatalogService.Tests.Unit/CatalogService.Tests.Unit.csproj
dotnet test tests/PricingService.Tests.Unit/PricingService.Tests.Unit.csprojRun integration tests:
dotnet test tests/CatalogService.Tests.Integration/CatalogService.Tests.Integration.csproj
dotnet test tests/PricingService.Tests.Integration/PricingService.Tests.Integration.csproj
dotnet test tests/ServiceCommunication.Tests.Integration/ServiceCommunication.Tests.Integration.csprojIntegration tests require Docker because they use PostgreSQL Testcontainers.
They do not require manually running Docker Compose.
The project collects coverage using Coverlet:
dotnet test TwoServicesHttp.slnx \
--collect:"XPlat Code Coverage" \
--settings coverlet.runsettings \
--results-directory TestResultsCoverage output is generated as:
coverage.cobertura.xml
The CI pipeline also generates an HTML coverage report using ReportGenerator.
To generate the report locally:
dotnet tool restore
dotnet tool run reportgenerator -- \
-reports:"TestResults/**/coverage.cobertura.xml" \
-targetdir:"coveragereport" \
-reporttypes:"Html;HtmlSummary;Cobertura;MarkdownSummaryGithub" \
-assemblyfilters:"+CatalogService.*;+PricingService.*;-*.Tests.*" \
-classfilters:"-Microsoft.AspNetCore.OpenApi.Generated.*" \
-filefilters:"-**/obj/**;-**/*.g.cs;-**/*.generated.cs;-**/*SourceGenerators*"Open:
coveragereport/index.html
The project also contains a coverage threshold check:
scripts/check-coverage.ps1
Current minimum line coverage:
60 %
The repository contains a GitHub Actions workflow:
.github/workflows/ci.yml
The pipeline runs on:
push
pull_request
workflow_dispatch
It contains these jobs:
build-and-test
docker-compose-smoke-test
docker-security-scan
This job performs:
dotnet tool restore
dotnet restore
dotnet format --verify-no-changes
dotnet build
dotnet test with coverage
HTML coverage report generation
coverage threshold check
coverage artifact upload
Artifacts:
coverage-report
raw-coverage-files
This job performs:
docker compose config
docker compose up --build --wait
Catalog /health/ready check
Pricing /health/ready check
docker compose down --volumes cleanup
This verifies that the Docker Compose stack can be built and started in CI.
It is intentionally a smoke test.
It does not run the full business scenario because EF Core migrations are not automatically applied in the Docker Compose startup flow.
This job builds Docker images for:
Catalog Service API
Pricing Service API
Then it performs vulnerability scanning using Anchore/Grype.
The scan fails the build for fixable high severity vulnerabilities.
Artifacts:
catalog-service-vulnerability-scan
pricing-service-vulnerability-scan
The CI pipeline generates SBOM files for both Docker images.
SBOM stands for:
Software Bill of Materials
It is a machine-readable list of software components included in the image.
Generated SBOM files:
catalog-service.spdx.json
pricing-service.spdx.json
Artifact:
docker-image-sboms
The SBOM format is:
SPDX JSON
The repository contains Dependabot configuration:
.github/dependabot.yml
Dependabot checks:
NuGet packages
GitHub Actions
NuGet package versions are centralized in:
Directory.Packages.props
The repository uses:
Directory.Build.props
Directory.Packages.props
.editorconfig
Purpose:
| File | Purpose |
|---|---|
Directory.Build.props |
Shared MSBuild settings. |
Directory.Packages.props |
Central NuGet package versions. |
.editorconfig |
Shared formatting and C# style rules. |
Detailed architecture decisions are documented in:
docs/adr/
Each service has its own PostgreSQL database.
Catalog Service does not access Pricing Service database directly.
This keeps service ownership clear.
A shared database would make synchronous reads easier, but it would tightly couple both services and break service boundaries.
This project intentionally avoids that.
The project is intentionally small.
An API Gateway would add extra infrastructure without helping the main learning goal.
Authentication and authorization are out of scope.
The focus is HTTP communication between services.
This project focuses on synchronous HTTP communication.
Asynchronous messaging with RabbitMQ, Kafka, or Azure Service Bus belongs to a separate project.
Migrations are applied manually.
This keeps schema changes explicit and visible.
In production-like systems, migrations are usually handled as an explicit deployment step.
Synchronous HTTP communication is simple to understand and easy to implement.
But it introduces coupling:
Catalog Service availability may depend on Pricing Service availability.
Catalog Service latency may increase because it waits for Pricing Service.
Pricing Service failures must be handled carefully.
Retries can help transient failures but can also increase load.
Fallbacks help preserve partial functionality.
This project demonstrates these issues in a small and understandable way.
Stop containers:
docker compose downStop containers and remove volumes:
docker compose down -vAfter removing volumes, apply EF Core migrations again before testing business endpoints.
Implemented:
- Catalog Service
- Pricing Service
- PostgreSQL per service
- EF Core migrations
- API versioning
- Swagger/OpenAPI
- health checks
- Docker Compose health checks
- timeout
- retry
- fallback
- Problem Details
- unit tests
- integration tests
- Catalog-to-Pricing HTTP integration test
- code coverage collection
- HTML coverage report
- coverage threshold check
- GitHub Actions CI
- Docker Compose smoke test in CI
- Docker image vulnerability scanning
- Docker image SBOM generation
- non-root API containers
- Dependabot
- central package management
- shared build settings
.editorconfig
Not implemented:
- authentication
- API Gateway
- message broker
- distributed tracing
- metrics dashboard
- Kubernetes
- production deployment
- automatic migrations on startup