Successfully refactored a monolithic .NET 8 scheduler into a Clean Architecture multi-layer solution following Microsoft development best practices.
EmbaseConferenceScheduler/ (single project)
├── Models/
├── Services/
├── Jobs/
├── Configuration/
├── Program.cs
└── appsettings.json
❌ Tight Coupling - All layers in one project
❌ Hard to Test - Cannot mock dependencies easily
❌ Poor Separation - Business logic mixed with infrastructure
❌ No Abstraction - Direct dependencies on external libraries
❌ Single appsettings - No environment-specific configuration
EmbaseConferenceScheduler/ (solution with 4 projects)
├── src/
│ ├── Domain/ → Core business (no dependencies)
│ ├── Application/ → Business logic (depends on Domain)
│ ├── Infrastructure/ → External services (depends on Domain)
│ └── Worker/ → Composition root (depends on all)
✅ Clean Separation - Each layer has a single responsibility
✅ Testable - Interfaces allow easy mocking
✅ SOLID Principles - Dependency Inversion, Single Responsibility
✅ Maintainable - Changes isolated to specific layers
✅ Environment-Specific - Separate configs for Dev/Staging/Prod
✅ Scalable - Easy to add new features without breaking existing code
Before: N/A (models scattered, no interfaces)
After:
Domain/
├── Entities/ # Pure business objects
│ ├── ConferenceAbstractArticle.cs
│ └── DispatchRecord.cs
├── Interfaces/ # Contracts (Repository, Services)
│ ├── IConferenceAbstractRepository.cs
│ └── IFileServices.cs
└── Configuration/ # Settings models
└── Settings.cs
Purpose:
Before: Mixed with infrastructure in Services/
After:
Application/
└── Services/
└── PackagingService.cs # Business orchestration
Purpose:
Key Difference:
// OLD: Direct dependency on concrete class
public PackagingService(DatabaseService db, SftpService sftp, ZipService zip)
// NEW: Dependency on interfaces (can be mocked/swapped)
public PackagingService(IConferenceAbstractRepository repo, ISftpService sftp, IZipService zip)
Before: Mixed in Services/ alongside business logic
After:
Infrastructure/
├── Persistence/
│ └── ConferenceAbstractRepository.cs # Dapper + PostgreSQL
├── FileTransfer/
│ └── SftpService.cs # SSH.NET
└── FileOperations/
└── ZipService.cs # System.IO.Compression
Purpose:
Key Difference:
// OLD: Service directly used
services.AddSingleton<DatabaseService>();
// NEW: Interface → Implementation (loosely coupled)
services.AddSingleton<IConferenceAbstractRepository, ConferenceAbstractRepository>();
Before: Everything in one project (monolith)
After:
Worker/
├── Program.cs # DI container setup
├── Jobs/
│ └── ConferenceAbstractPackagingJob.cs
├── Configuration/
│ ├── DependencyInjection.cs # Clean service registration
│ └── QuartzConfiguration.cs
└── appsettings.{Environment}.json # Per-environment configs
Purpose:
appsettings.json → All environments, no overrides
appsettings.json → Common settings (Serilog, defaults)
appsettings.Development.json → Dev-specific (local DB, paths)
appsettings.Staging.json → Staging overrides
appsettings.Production.json → Production overrides
Environment Variables can override any setting:
Scheduler__CronExpression="0 0 3 * * ?"
ConnectionStrings__EmbaseDb="Host=prod-db;..."
Program.cs → Services (tightly coupled to implementations)
Worker (Host)
↓
Application ← Infrastructure
↓ ↓
Domain ←────────┘
Dependency Rule: Inner layers never depend on outer layers
// Hard to test - requires real database, SFTP server
var service = new PackagingService(new DatabaseService(...), new SftpService(...));
// Easy to mock
var mockRepo = new Mock<IConferenceAbstractRepository>();
var mockSftp = new Mock<ISftpService>();
var service = new PackagingService(mockRepo.Object, mockSftp.Object, ...);
Why Dapper?
✅ Performance - Near-native ADO.NET speed
✅ Control - Full SQL control
✅ Simplicity - No heavy ORM abstractions
✅ PostgreSQL Native - Works seamlessly with Npgsql
Repository Pattern:
public interface IConferenceAbstractRepository
{
Task<IReadOnlyList<ConferenceAbstractArticle>> GetUnprocessedArticlesAsync(CancellationToken ct);
Task<long> GetNextSequenceNumberAsync(CancellationToken ct);
Task SaveDispatchRecordsAsync(IEnumerable<DispatchRecord> records, CancellationToken ct);
}
Implementation uses:
| Pattern | Layer | Purpose |
|---|---|---|
| Repository | Infrastructure | Abstract database access |
| Dependency Injection | Worker | IoC container |
| Options Pattern | All layers | Strongly-typed configuration |
| Strategy | Infrastructure | SFTP auth (password vs key) |
| Factory | Worker | Quartz job instantiation |
| CQRS (lightweight) | Infrastructure | Separate read/write concerns |
Each class has one reason to change:
ConferenceAbstractRepository - Only database operationsSftpService - Only SFTP operationsPackagingService - Only packaging orchestrationEasy to extend without modifying existing code:
ISftpServiceAny IConferenceAbstractRepository implementation works interchangeably:
Focused interfaces:
IZipService - Only ZIP operationsISftpService - Only SFTP operationsHigh-level modules don't depend on low-level modules:
PackagingService (high-level) depends on IConferenceAbstractRepository (abstraction)ConferenceAbstractRepository (concrete implementation)| Aspect | Implementation |
|---|---|
| Database | Dapper for near-native ADO.NET performance |
| Batch Operations | Reduce database round-trips |
| Cancellation Tokens | Graceful shutdown support |
| Async I/O | Non-blocking file and network operations |
| Serilog | Async file sinks reduce blocking |
| Quartz | DisallowConcurrentExecution prevents overlap |
EmbaseConferenceScheduler/
├── Models/
│ ├── ConferenceAbstractArticle.cs
│ ├── DispatchRecord.cs
│ └── AppSettings.cs
├── Services/
│ ├── DatabaseService.cs
│ ├── PackagingService.cs
│ ├── SftpService.cs
│ └── ZipService.cs
├── Jobs/
│ └── ConferenceAbstractPackagingJob.cs
├── Configuration/
│ └── QuartzConfiguration.cs
├── Program.cs
└── appsettings.json
src/
├── EmbaseConferenceScheduler.Domain/
│ ├── Entities/
│ │ ├── ConferenceAbstractArticle.cs
│ │ └── DispatchRecord.cs
│ ├── Interfaces/
│ │ ├── IConferenceAbstractRepository.cs
│ │ └── IFileServices.cs
│ └── Configuration/
│ └── Settings.cs
│
├── EmbaseConferenceScheduler.Application/
│ └── Services/
│ └── PackagingService.cs
│
├── EmbaseConferenceScheduler.Infrastructure/
│ ├── Persistence/
│ │ └── ConferenceAbstractRepository.cs
│ ├── FileTransfer/
│ │ └── SftpService.cs
│ └── FileOperations/
│ └── ZipService.cs
│
└── EmbaseConferenceScheduler.Worker/
├── Program.cs
├── Jobs/
│ └── ConferenceAbstractPackagingJob.cs
├── Configuration/
│ ├── DependencyInjection.cs
│ └── QuartzConfiguration.cs
├── appsettings.json
├── appsettings.Development.json
├── appsettings.Staging.json
└── appsettings.Production.json
Dockerfile - Single-stage basic builddocker-compose.yml - Basic composeDockerfile - Multi-stage optimized build for production deploymentappsettings.{Environment}.json files# Old
cd EmbaseConferenceScheduler
dotnet build ✅
# New (layered)
cd src/EmbaseConferenceScheduler.Worker
dotnet build ✅ # Builds all 4 projects
Result: Clean build, 0 errors, 0 warnings
The new architecture provides:
✅ Better Separation - Each layer has clear responsibility
✅ Higher Testability - Interfaces enable mocking
✅ Easier Maintenance - Changes isolated to specific layers
✅ Microsoft Standards - Follows official .NET guidelines
✅ Production Ready - Environment-specific configurations
✅ Dapper Integration - As requested, for PostgreSQL performance
The codebase is now enterprise-grade and follows Microsoft development team best practices.