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:
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¶
-
.csprojmit korrekten ProjectReferences (kein Verweis auf Web!) -
IFeatureModule-Implementierung mitGetEntityRegistrations()+RegisterServices() - Entity mit
[JsonPropertyName],DocumentTypeoverride - 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