Zum Inhalt
v26.3

DiKAS Plugin-Entwickler-Guide

Anleitung zum Erstellen von Feature-Modulen (Plugins) für das DiKAS-System.

Architektur-Überblick

Dikas.Api.Domain          ← Entities, Interfaces, Exceptions (keine Dependencies)
Dikas.Api.Application     ← CQRS Handlers, Services, Behaviors
Dikas.Api.Contracts       ← Request/Response DTOs (kein Domain-Bezug)
Dikas.Api.Infrastructure  ← DB, Caching, externe Services
Dikas.Api.Web             ← ASP.NET Core Host (lädt Plugins dynamisch)
Dikas.Features.*          ← Feature-Module (Plugins)

Feature-Module sind vollständig entkoppelt von Dikas.Api.Web. Sie werden zur Laufzeit dynamisch geladen — entweder als DLL im plugins/-Ordner oder als .feature ZIP-Paket.


1. Neues Feature-Modul erstellen

Projekt-Struktur

Dikas.Features.MeinFeature/
├── Dikas.Features.MeinFeature.csproj
├── MeinFeatureModule.cs           ← IFeatureModule Implementation
├── Entities/
│   └── MeineEntity.cs             ← Domain Entity
├── Commands/
│   ├── CreateMeineEntityCommand.cs
│   └── UpdateMeineEntityCommand.cs
├── Queries/
│   └── GetMeineEntityQuery.cs
├── Contracts/
│   ├── MeineRequests.cs
│   ├── MeineResponses.cs
│   └── MeineMappings.cs
├── Controllers/
│   └── MeinController.cs
└── Services/
    └── MeinService.cs              ← (optional)

.csproj Konfiguration

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Dikas.Api.Application\Dikas.Api.Application.csproj" />
    <ProjectReference Include="..\Dikas.Api.Contracts\Dikas.Api.Contracts.csproj" />
    <ProjectReference Include="..\Dikas.Api.Infrastructure\Dikas.Api.Infrastructure.csproj" />
    <!-- NICHT auf Dikas.Api.Web verweisen! -->
  </ItemGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

  <!-- Plugin-DLL nach plugins/ kopieren -->
  <Target Name="CopyToPlugins" AfterTargets="Build" Condition="'$(CopyToPlugins)' == 'true'">
    <MakeDir Directories="$(SolutionDir)Dikas.Api.Web/plugins/" />
    <Copy SourceFiles="$(OutputPath)$(AssemblyName).dll"
          DestinationFolder="$(SolutionDir)Dikas.Api.Web/plugins/" />
  </Target>
</Project>

2. IFeatureModule implementieren

Jedes Plugin muss genau eine Klasse haben, die IFeatureModule implementiert:

using Dikas.Api.Domain.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Dikas.Features.MeinFeature;

public class MeinFeatureModule : IFeatureModule
{
    public string Name => "MeinFeature";

    public IEnumerable<EntityRegistration> GetEntityRegistrations()
    {
        yield return new EntityRegistration(
            EntityType: typeof(MeineEntity),
            DocumentType: "MeineEntity",
            CacheAll: true,           // true für Stammdaten, false für Transaktionsdaten
            BackupCategory: "meinfeature",  // Kategorie für selektives Backup/Restore
            ConfigureEfCore: mb => ConfigureMeineEntity((ModelBuilder)mb)
        );
    }

    public void RegisterServices(IServiceCollection services, IConfiguration configuration)
    {
        // Eigene Services registrieren
        services.AddSingleton<IMeinService, MeinService>();

        // Repository-Registrierung (Dual-DB: CouchDB + SQL)
        var dbOptions = configuration.GetSection("Database").Get<DatabaseOptions>() ?? new();

        switch (dbOptions.Provider)
        {
            case DatabaseProvider.Sqlite:
            case DatabaseProvider.SqlServer:
                services.AddScoped<IRepository<MeineEntity>>(sp =>
                    new SqlRepository<MeineEntity>(
                        sp.GetRequiredService<DikasDbContext>(),
                        sp.GetRequiredService<IMemoryCache>(),
                        sp.GetRequiredService<ILogger<SqlRepository<MeineEntity>>>(),
                        cacheAll: true));
                break;

            default: // CouchDB
                services.AddScoped<IRepository<MeineEntity>>(sp =>
                    new CouchDbRepository<MeineEntity>(
                        sp.GetRequiredService<ICouchDbClient>(),
                        sp.GetRequiredService<IMemoryCache>(),
                        sp.GetRequiredService<ILogger<CouchDbRepository<MeineEntity>>>(),
                        cacheAll: true));
                break;
        }
    }
}

EntityRegistration Parameter

Parameter Beschreibung Beispiel
EntityType CLR-Typ der Entity typeof(MeineEntity)
DocumentType CouchDB DocumentType String "MeineEntity"
LegacyDocumentType Alter DocumentType (Migration) "AlteName"
CacheAll Alle Dokumente im MemoryCache halten true für Stammdaten
DatabaseName Alternatives CouchDB-Datenbank "gastrocurrent"
BackupCategory Backup/Restore Kategorie "meinfeature"
ConfigureEfCore EF Core ModelBuilder Callback mb => Configure(...)

3. Entity definieren

using System.Text.Json.Serialization;
using Dikas.Api.Domain.Entities.Base;

namespace Dikas.Features.MeinFeature.Entities;

/// <summary>
/// Für löschbare Entities: SoftDeletableDocument
/// Für reguläre Entities: BaseDocument
/// </summary>
public class MeineEntity : SoftDeletableDocument
{
    public override string DocumentType => "MeineEntity";

    [JsonPropertyName("name")]
    public string Name { get; set; } = "";

    [JsonPropertyName("beschreibung")]
    public string? Beschreibung { get; set; }

    [JsonPropertyName("preis")]
    public decimal Preis { get; set; }

    [JsonPropertyName("isActive")]
    public bool IsActive { get; set; } = true;
}

Wichtig: - Alle Properties mit [JsonPropertyName("...")] annotieren - DocumentType als override bereitstellen (wird für CouchDB-Filterung verwendet) - SoftDeletableDocument für Entities die soft-gelöscht werden (haben IsDeleted/DeletedDate) - BaseDocument für reguläre Entities


4. CQRS Commands & Queries

Command (Schreiboperation)

using Dikas.Api.Application.Common.Interfaces;
using Dikas.Api.Domain.Exceptions;
using Dikas.Api.Domain.Interfaces;
using Dikas.Features.MeinFeature.Contracts;
using FluentValidation;
using MediatR;

namespace Dikas.Features.MeinFeature.Commands;

public record CreateMeineEntityCommand(
    string Name,
    string? Beschreibung,
    decimal Preis
) : IRequest<MeineEntityResponse>;

public class CreateMeineEntityCommandValidator : AbstractValidator<CreateMeineEntityCommand>
{
    public CreateMeineEntityCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Preis).GreaterThanOrEqualTo(0);
    }
}

public class CreateMeineEntityCommandHandler
    : IRequestHandler<CreateMeineEntityCommand, MeineEntityResponse>
{
    private readonly IRepository<MeineEntity> _repo;
    private readonly IIdGenerator _idGen;
    private readonly ICurrentUser _currentUser;

    public CreateMeineEntityCommandHandler(
        IRepository<MeineEntity> repo,
        IIdGenerator idGen,
        ICurrentUser currentUser)
    {
        _repo = repo;
        _idGen = idGen;
        _currentUser = currentUser;
    }

    public async Task<MeineEntityResponse> Handle(
        CreateMeineEntityCommand request, CancellationToken ct)
    {
        var entity = new MeineEntity
        {
            Id = _idGen.Generate("MeineEntity"),
            Name = request.Name,
            Beschreibung = request.Beschreibung,
            Preis = request.Preis,
            CreatedBy = _currentUser.UserId,
            ChangedBy = _currentUser.UserId
        };

        await _repo.SaveAsync(entity, ct);
        return entity.ToResponse();
    }
}

Query (Leseoperation)

public record GetAllMeineEntitiesQuery(string? Search)
    : IRequest<IReadOnlyList<MeineEntityResponse>>;

public class GetAllMeineEntitiesQueryHandler
    : IRequestHandler<GetAllMeineEntitiesQuery, IReadOnlyList<MeineEntityResponse>>
{
    private readonly IRepository<MeineEntity> _repo;
    public GetAllMeineEntitiesQueryHandler(IRepository<MeineEntity> repo) => _repo = repo;

    public async Task<IReadOnlyList<MeineEntityResponse>> Handle(
        GetAllMeineEntitiesQuery request, CancellationToken ct)
    {
        var all = await _repo.GetAllAsync(ct);
        IEnumerable<MeineEntity> filtered = all;

        if (!string.IsNullOrWhiteSpace(request.Search))
            filtered = filtered.Where(e =>
                e.Name.Contains(request.Search, StringComparison.OrdinalIgnoreCase));

        return filtered.Select(e => e.ToResponse()).ToList();
    }
}

5. Contracts (DTOs)

Responses

namespace Dikas.Features.MeinFeature.Contracts;

public class MeineEntityResponse
{
    public string Id { get; init; } = "";
    public string Name { get; init; } = "";
    public string? Beschreibung { get; init; }
    public decimal Preis { get; init; }
    public bool IsActive { get; init; }
    public DateTime CreatedDate { get; init; }
    public DateTime ChangedDate { get; init; }
}

Requests

public class CreateMeineEntityRequest
{
    public string Name { get; init; } = "";
    public string? Beschreibung { get; init; }
    public decimal Preis { get; init; }
}

Mappings

public static class MeineMappings
{
    public static MeineEntityResponse ToResponse(this MeineEntity e) => new()
    {
        Id = e.Id,
        Name = e.Name,
        Beschreibung = e.Beschreibung,
        Preis = e.Preis,
        IsActive = e.IsActive,
        CreatedDate = e.CreatedDate,
        ChangedDate = e.ChangedDate
    };
}

6. Controller

using Dikas.Api.Contracts.Common;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Dikas.Features.MeinFeature.Controllers;

[ApiController]
[Route("api/v1/mein-feature")]
[Authorize]
[Produces("application/json")]
public class MeinController : ControllerBase
{
    private readonly IMediator _mediator;
    public MeinController(IMediator mediator) => _mediator = mediator;

    [HttpGet]
    public async Task<ActionResult<ApiResponse<IReadOnlyList<MeineEntityResponse>>>> GetAll(
        [FromQuery] string? search, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetAllMeineEntitiesQuery(search), ct);
        return Ok(ApiResponse<IReadOnlyList<MeineEntityResponse>>.Ok(result));
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ApiResponse<MeineEntityResponse>>> GetById(
        string id, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetMeineEntityByIdQuery(id), ct);
        return Ok(ApiResponse<MeineEntityResponse>.Ok(result));
    }

    [HttpPost]
    public async Task<ActionResult<ApiResponse<MeineEntityResponse>>> Create(
        [FromBody] CreateMeineEntityRequest req, CancellationToken ct)
    {
        var result = await _mediator.Send(
            new CreateMeineEntityCommand(req.Name, req.Beschreibung, req.Preis), ct);
        return CreatedAtAction(nameof(GetById), new { id = result.Id },
            ApiResponse<MeineEntityResponse>.Ok(result));
    }
}

Konventionen: - Immer [Authorize] auf dem Controller - ApiResponse<T>.Ok(result) für Erfolgs-Antworten - CreatedAtAction für 201 Created bei POST - CancellationToken ct als letzter Parameter


7. EF Core Konfiguration

Für SQL-Datenbank-Unterstützung muss jede Entity eine EF Core Konfiguration haben:

private static void ConfigureMeineEntity(ModelBuilder mb)
{
    var e = mb.Entity<MeineEntity>();
    e.ToTable("MeineEntities");                      // Tabellenname
    e.Property(x => x.Name).HasMaxLength(200);       // String-Limits
    e.Property(x => x.Preis).HasPrecision(18, 4);   // Dezimal-Precision

    // JSON-Listen als TEXT-Spalten
    e.Property(x => x.Items)
        .HasColumnType("TEXT")
        .HasConversion(ListJsonConverter<MeinItem>())
        .Metadata.SetValueComparer(ListComparer<MeinItem>());
}

Nach dem Hinzufügen neuer Entities: EF Core Migration erstellen!

cd Dikas.Api && dotnet ef migrations add AddMeineEntity \
    --project Dikas.Api.Infrastructure \
    --startup-project Dikas.Api.Web \
    --output-dir Database/Sql/Migrations

8. Tests

Tests liegen in Dikas.Api.Tests/ (hat ProjectReference auf alle Feature-Module).

using Dikas.Api.Domain.Interfaces;
using Dikas.Features.MeinFeature.Commands;
using Moq;

namespace Dikas.Api.Tests.MeinFeature;

public class MeinFeatureTests
{
    private readonly Mock<IRepository<MeineEntity>> _repoMock = new();
    private readonly Mock<IIdGenerator> _idGenMock = new();
    private readonly Mock<ICurrentUser> _currentUserMock = new();

    [Fact]
    public async Task Create_ReturnsNewEntity()
    {
        // Arrange
        _idGenMock.Setup(g => g.Generate("MeineEntity")).Returns("me_1");
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<MeineEntity>(),
            It.IsAny<CancellationToken>())).ReturnsAsync(true);

        var handler = new CreateMeineEntityCommandHandler(
            _repoMock.Object, _idGenMock.Object, _currentUserMock.Object);

        // Act
        var result = await handler.Handle(
            new CreateMeineEntityCommand("Test", null, 9.99m),
            CancellationToken.None);

        // Assert
        Assert.Equal("me_1", result.Id);
        Assert.Equal("Test", result.Name);
        Assert.Equal(9.99m, result.Preis);
    }
}

Konventionen: - xUnit + Moq - Arrange/Act/Assert Pattern - Namenskonvention: MethodName_Scenario_ExpectedResult - Interface-Mocks für alle Dependencies

Tests ausführen:

dotnet test Dikas.Api.Tests -p:AllowMissingPrunePackageData=true

9. Deployment

Option A: DLL nach plugins/ kopieren

# Build mit CopyToPlugins Flag
dotnet build Dikas.Features.MeinFeature -p:CopyToPlugins=true

# Oder manuell
cp Dikas.Features.MeinFeature/bin/Debug/net10.0/Dikas.Features.MeinFeature.dll \
   Dikas.Api.Web/plugins/

Option B: .feature ZIP-Paket

Eine .feature-Datei ist ein ZIP-Archiv:

meinfeature.feature (ZIP)
├── manifest.json
├── backend/
│   └── Dikas.Features.MeinFeature.dll
└── frontend/
    └── meinfeature/
        └── meinfeature.extension.js

manifest.json:

{
  "name": "MeinFeature",
  "version": "1.0.0",
  "description": "Beschreibung des Features",
  "backend": {
    "dll": "backend/Dikas.Features.MeinFeature.dll"
  },
  "frontend": {
    "path": "frontend/meinfeature",
    "extensionModule": "meinfeature.extension.js",
    "routeBase": "mein-feature"
  },
  "requires": {
    "dikasMinVersion": "26.0"
  }
}

ZIP-Pakete werden beim Start aus dem features/-Ordner extrahiert.


10. Frontend-Extension (Angular)

Nx Library erstellen

cd dikas-next
npx nx g @nx/angular:library features-mein-feature --directory=libs/features/mein-feature

Extension registrieren

// libs/features/mein-feature/src/lib/meinfeature.extension.ts
import { ExtensionRegistryService } from '@dikas/core';

export function registerMeinFeatureExtension(registry: ExtensionRegistryService) {
  // Sidebar-Link
  registry.registerSidebarItem({
    label: 'Mein Feature',
    icon: 'fa-solid fa-puzzle-piece',
    routerLink: '/admin/mein-feature',
    order: 80
  });

  // Route
  registry.registerRoute({
    path: 'mein-feature',
    loadComponent: () => import('./components/mein-feature.component')
      .then(m => m.MeinFeatureComponent)
  });

  // Tab-Extension (z.B. in Kunden-Detail)
  registry.registerTabExtension('customer-detail', {
    label: 'Mein Feature',
    order: 50,
    loadComponent: () => import('./components/customer-tab.component')
      .then(m => m.CustomerTabComponent)
  });
}

Service

@Injectable({ providedIn: 'root' })
export class MeinFeatureService {
  private api = inject(ApiService);

  getAll(search?: string) {
    const params = search ? `?search=${encodeURIComponent(search)}` : '';
    return this.api.get<MeineEntityResponse[]>(`/api/v1/mein-feature${params}`);
  }

  getById(id: string) {
    return this.api.get<MeineEntityResponse>(`/api/v1/mein-feature/${id}`);
  }

  create(request: CreateMeineEntityRequest) {
    return this.api.post<MeineEntityResponse>('/api/v1/mein-feature', request);
  }
}

Referenz-Implementierungen

Feature Komplexität Besonderheiten
Disco Einfach 3 Entities, CRUD, CouchDB "gastrocurrent" DB
Licensing Komplex PKI, Import, Billing-Sync, Rate-Limiting, Legacy-API
OnlineOrder Mittel SignalR, Tests, Web-Tab

Disco als Minimal-Beispiel

Dikas.Features.Disco/
├── DiscoModule.cs            ← IFeatureModule (3 EntityRegistrations)
├── Entities/                 ← DiscoGuest, DiscoEnterGroup, DiscoDayLog
├── Commands/                 ← EnterGuest, PayGuest, CloseDay, etc.
├── Queries/                  ← GetGuests, GetDayLog, GetStats
├── Contracts/                ← Requests, Responses, Mappings
└── Controllers/              ← DiscoGuestController, DiscoConfigController, DiscoStatsController

Licensing als vollständiges Beispiel

Dikas.Features.Licensing/
├── LicensingModule.cs        ← IFeatureModule (2 EntityRegistrations + Services)
├── Entities/                 ← LicenseRecord, LicenseActivationLog, LicenseModule
├── Commands/                 ← Create/Update/Delete/Transfer/Import/Sync/Sign/Init
├── Queries/                  ← ById/All/ByCustomer/ByKey/Expiring/Stats
├── Contracts/                ← Requests, Responses, Mappings, ImportContracts
├── Controllers/              ← LicenseController, LicenseActivationController,
│                                LicenseImportController, LegacyLicenseController
├── Services/                 ← RateLimiter, Provisioning, CaService
└── KnownLicenseModules.cs    ← Modul-Konstanten

Checkliste für neue Plugins

  • .csproj mit korrekten ProjectReferences (kein Verweis auf Web!)
  • IFeatureModule-Implementierung mit GetEntityRegistrations() + RegisterServices()
  • Entity mit [JsonPropertyName], DocumentType override
  • Dual-DB Repository-Registrierung (CouchDB + SQL)
  • EF Core Konfiguration für SQL-Modus
  • CQRS Commands/Queries mit FluentValidation
  • Controller mit [Authorize], ApiResponse<T>
  • Contracts (Request/Response DTOs, Mappings)
  • Tests (xUnit + Moq, Arrange/Act/Assert)
  • CopyToPlugins MSBuild Target
  • EF Core Migration erstellt (dotnet ef migrations add ...)
  • BackupCategory gesetzt für Backup/Restore-Unterstützung