Documentation
reference/patterns/architectural-patterns.md
Dynaplex Architectural Patterns: Quick Reference
Quick lookup for common patterns when working with Dynaplex components
Component Structure Pattern
engines/{component-name}/
├── src/
│ ├── Acsis.Dynaplex.Engines.{Name}.Abstractions/ ← Interfaces, models, configs
│ ├── Acsis.Dynaplex.Engines.{Name}/ ← Implementation (ASP.NET service)
│ ├── Acsis.Dynaplex.Engines.{Name}.ApiClient/ ← Generated Kiota client
│ └── Acsis.Dynaplex.Engines.{Name}.DbMigrator/ ← Database migrations
├── tests/
│ └── Acsis.Dynaplex.Engines.{Name}.Tests/
└── resources/
├── README.md ← Component overview
└── *.md ← Architecture docs
Key Rule: Components can ONLY reference other component .Abstractions projects, never implementation projects.
✅ Correct: BBU → Edge.Abstractions
❌ Wrong: BBU → Edge
Abstract Base Class Placement
Rule: If it's abstract and provides contract/infrastructure, it belongs in .Abstractions.
Edge.Abstractions/Services/
├── MqttProcessorBase.cs ← Abstract base for MQTT processors
└── ZebraRfidProcessor.cs ← Abstract base for Zebra RFID
Edge/Services/
├── SpecificImplementation.cs ← Concrete implementations only
Why: Other components need to extend base classes without creating implementation dependencies.
Service Lifetime Pattern
Problem
// ❌ WRONG - Cannot inject scoped service into singleton
public class MyHostedService : IHostedService {
public MyHostedService(ILogger logger, MyDbContext db) { // ERROR!
// DbContext is scoped, IHostedService is singleton
}
}
Solution
// ✅ CORRECT - Inject IServiceProvider, create scopes when needed
public class MyHostedService : IHostedService {
private readonly IServiceProvider _serviceProvider;
public MyHostedService(ILogger logger, IServiceProvider serviceProvider) {
_serviceProvider = serviceProvider;
}
private async Task DoWorkAsync() {
await using var scope = _serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
// Use db within scope
await db.SaveChangesAsync();
}
}
Pattern: Singleton → IServiceProvider → Scope → Scoped Service
PostgreSQL DateTime Pattern
Problem
// ❌ WRONG - DateTimeOffset with non-UTC offset
public DateTimeOffset Timestamp { get; set; }
entity.Timestamp = zebraReader.Timestamp; // Might have offset -04:00:00
// ERROR: Cannot write DateTimeOffset with Offset=-04:00:00 to PostgreSQL
Solution
// ✅ CORRECT - Use DateTime (UTC)
public DateTime Timestamp { get; set; }
entity.Timestamp = zebraReader.Timestamp.UtcDateTime; // Convert to UTC
entity.CapturedAt = DateTime.UtcNow; // Always UTC
Rules:
- PostgreSQL
timestamptzonly accepts UTC (offset 0) - Use
DateTime(notDateTimeOffset) for PostgreSQL columns - Always convert:
.UtcDateTimeorDateTime.UtcNow
MQTT Processor Pattern
Template
using Acsis.Dynaplex.Engines.Edge.Abstractions.Services;
using Acsis.Dynaplex.Engines.Edge.Abstractions.Configuration;
public class MyProcessor : MqttProcessorBase<MqttProcessorConfiguration> {
public MyProcessor(
ILogger<MyProcessor> logger,
IOptions<MqttProcessorConfiguration> config
) : base(logger, config) { }
protected override async Task OnMessageReceivedAsync(
string topic,
string payload,
MqttApplicationMessageReceivedEventArgs eventArgs) {
// Your business logic here
Logger.LogInformation("Processing {Topic}", topic);
await Task.CompletedTask;
}
}
Registration
// Program.cs
var mqttConfig = builder.Configuration.GetSection("MyComponent:Mqtt");
var mqttConfigValue = mqttConfig.Get<MqttProcessorConfiguration>();
if(mqttConfig.Exists() && mqttConfigValue?.Name != null) {
builder.Services.Configure<MqttProcessorConfiguration>(mqttConfig);
builder.Services.AddHostedService<MyProcessor>();
}
Essential Configuration
{
"Mqtt": {
"Name": "My Processor",
"BrokerHost": "mqtt.example.com",
"BrokerPort": 8883,
"ClientId": "my-client",
"SubscribeTopics": ["my/topic/+"],
"SharedSubscriptionGroup": "my-group", // ← Load balancing
"CleanSession": false, // ← Preserve messages
"AppendUniqueIdToClientId": true // ← Prevent takeover
}
}
Key Settings:
SharedSubscriptionGroup: Load balance across instancesCleanSession: false: Don't lose messages during disconnectsAppendUniqueIdToClientId: true: Prevent session takeover
Zebra RFID Processor Pattern
Template
using Acsis.Dynaplex.Engines.Edge.Abstractions.Services;
using Acsis.Dynaplex.Engines.Edge.Abstractions.Configuration;
using Acsis.Dynaplex.Engines.Edge.Abstractions.Zebra.Events.TagData;
public class MyZebraProcessor : ZebraRfidProcessor<ZebraRfidProcessorConfiguration> {
public MyZebraProcessor(
ILogger<MyZebraProcessor> logger,
IOptions<ZebraRfidProcessorConfiguration> config,
IServiceProvider serviceProvider // For raw data capture
) : base(logger, config, serviceProvider) { }
protected override Task OnTagReadsAsync(string deviceId, List<BasicReadEvent> reads) {
foreach(var read in reads) {
var epc = read.Data.IdHex;
var rssi = read.Data.PeakRssi;
var antenna = read.Data.Antenna;
// Your business logic
}
return Task.CompletedTask;
}
}
Registration with Raw Data Capture
var mqttConfig = builder.Configuration.GetSection("MyComponent:Mqtt");
var mqttConfigValue = mqttConfig.Get<ZebraRfidProcessorConfiguration>();
if(mqttConfig.Exists() && mqttConfigValue?.Name != null) {
builder.Services.Configure<ZebraRfidProcessorConfiguration>(mqttConfig);
// Add EdgeDb if raw data capture enabled
if(mqttConfigValue.RawDataCapture.Enabled) {
builder.AddAcsisDbContext<EdgeDb>("edge");
}
builder.Services.AddHostedService<MyZebraProcessor>();
}
PostgreSQL Enums:
- Use
[PgName("schema.enum_name")]on the enum type and[PgName("value")]on members. AddAcsisDbContext<TContext>(...)auto-maps enums used by the DbContext; no manualNpgsqlConnection.GlobalTypeMappercalls.
What You Get Free:
- MQTT connection/reconnection
- Zebra message parsing
- Device ID extraction
- Optional raw data capture
- Message routing
Configuration Pattern
Component Configuration (Example/Template)
Location: engines/{name}/src/Acsis.Dynaplex.Engines.{Name}/appsettings.Example.json
{
"MyComponent": {
"Mqtt": {
"Name": "_example", // ← Underscore prefix = template
// ... example values
}
}
}
Project Configuration (Real Values)
Location: projects/{project}/src/Acsis.Dynaplex.Projects.{Project}/appsettings.json
{
"MyComponent": {
"Mqtt": {
"Name": "Production MQTT Client", // ← Real name
"BrokerHost": "prod.mqtt.example.com",
"Password": "real-password-here"
}
}
}
Pattern: Components have examples (_example), projects have real configs.
Raw Data Capture Pattern
Database Tables
// Edge database: 2-letter prefix for device/vendor
// - tn_* = TeletracNavman
// - zb_* = Zebra
[Table("zb_tag_reads")]
public class ZbTagRead {
[Key] public long Id { get; set; }
public string DeviceId { get; set; }
public DateTime EventTimestamp { get; set; } // ← UTC DateTime
// ... normalized fields
public string RawJson { get; set; } // ← Complete message
}
Configuration
{
"RawDataCapture": {
"Enabled": true,
"RetentionDays": 30,
"CaptureTagReads": true,
"CaptureManagementEvents": false,
"CaptureControlResponses": false,
"CaptureManagementResponses": false
}
}
Purpose: Audit trail, replay capability, troubleshooting
Testing Pattern
Unit Test Base
public abstract class ProcessorTestBase<TProcessor> {
protected Mock<ILogger<TProcessor>> MockLogger { get; }
protected Mock<IServiceProvider> MockServiceProvider { get; }
[Fact]
public abstract Task ProcessValidMessage_Success();
[Fact]
public abstract Task ProcessInvalidMessage_HandlesGracefully();
}
Integration Test Fixture
public class ComponentTestFixture : IAsyncLifetime {
public DbContext TestDb { get; private set; }
public IMqttServer MqttServer { get; private set; }
public async Task InitializeAsync() {
// Set up test infrastructure
}
public async Task DisposeAsync() {
// Clean up
}
}
Common Issues Checklist
Build Errors
- Check component references only
.Abstractionsprojects - Verify namespace matches file location
- Ensure all using statements are present
Runtime Errors
- Check service lifetime (singleton vs scoped)
- Verify DateTime is UTC for PostgreSQL
- Confirm MQTT config is valid (not
_example) - Check EdgeDb is registered if raw capture enabled
MQTT Issues
- Enable
AppendUniqueIdToClientIdfor session takeover - Use
SharedSubscriptionGroupfor load balancing - Set
CleanSession: falseto preserve messages - Verify broker connectivity and credentials
Quick Commands
Build Component
dotnet build engines/{name}/src/Acsis.Dynaplex.Engines.{Name}/
Generate Migration
cd engines/{name}/src/Acsis.Dynaplex.Engines.{Name}.Abstractions
dotnet ef migrations add {MigrationName} --context {Name}Db
Run Foundation
dotnet run --project projects/bbu-rfid/src/Acsis.Dynaplex.Projects.BbuRfid/
Test Component
dotnet test engines/{name}/tests/Acsis.Dynaplex.Engines.{Name}.Tests/
Documentation References
Detailed Guides:
Improvement Suggestions:
Repository Structure:
- CLAUDE.md - Architecture overview
Need Help?
- Check component
resources/folder for specific docs - Look for similar implementations in other components
- Review recent ADRs in
/docs/adrs/ - Search codebase for pattern examples
- Open GitHub Discussion with
architecturetag
Remember: When in doubt, follow existing patterns in the codebase!