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:

  1. PostgreSQL timestamptz only accepts UTC (offset 0)
  2. Use DateTime (not DateTimeOffset) for PostgreSQL columns
  3. Always convert: .UtcDateTime or DateTime.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 instances
  • CleanSession: false: Don't lose messages during disconnects
  • AppendUniqueIdToClientId: 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 manual NpgsqlConnection.GlobalTypeMapper calls.

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 .Abstractions projects
  • 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 AppendUniqueIdToClientId for session takeover
  • Use SharedSubscriptionGroup for load balancing
  • Set CleanSession: false to 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:


Need Help?

  1. Check component resources/ folder for specific docs
  2. Look for similar implementations in other components
  3. Review recent ADRs in /docs/adrs/
  4. Search codebase for pattern examples
  5. Open GitHub Discussion with architecture tag

Remember: When in doubt, follow existing patterns in the codebase!