Writing a Mod Backend
When designing a mod API, treat the mod as an autonomous package with a clear bounded context. The mod should provide a cohesive business capability, expose stable extension points, and avoid depending directly on the host application or other mods.
A well-designed mod follows the Open/Closed Principle and the Single Responsibility Principle:
- The mod should be open for extension through interfaces, decorators, events, GraphQL extensions, and configuration hooks.
- The mod should be closed for modification in normal host-specific use cases.
- The mod should be replaceable when a different implementation of the same bounded context is needed.
This allows the host application to adapt authorization, validation, query behavior, UI composition, and integration logic without changing the mod source code.
In practice, a mod is usually split into several packages. The core package contains the bounded context and public contracts, the API package exposes REST and GraphQL integration points, and the infrastructure package contains persistence and technical implementations. A mod may also provide optional addon packages for capabilities that are not required by every installation. For example, billing can keep the main billing model provider-agnostic and expose a Stripe addon package separately; coverages can keep clearing-house-specific behavior in a Stedi package.
The important rule is that optional packages should extend the mod through the same public configuration and service contracts as the host application. They should not force the core package to depend on a specific provider, external API, emulator, or deployment-specific integration.
Models
Start with the domain model. A mod follows the same tactical DDD patterns as the rest of the system: Entities, Value Objects, Aggregates, Repositories, Domain Services, and Domain Events.
The most important rule is that a mod must not directly interact with other mods or with the host application's domain model. Instead, it should expose well-defined interfaces and APIs.
There are two common model-design strategies:
- The mod uses a model provided by the host application when high extensibility is required.
mod-tasksis an example of this approach. - The mod declares and owns its own model when the domain is clear and product-specific extension is not expected.
mod-billingis an example of this approach.
Host-Provided Model
Use this approach when the mod must be highly extensible. In this case, the mod declares only interfaces and/or a base class with the fields and behavior required for the mod to perform its responsibilities.
For example:
public class TaskBase
{
public Guid Id { get; protected init; }
public TaskStatus Status { get; protected init; }
public string AssigneeId { get; protected set; }
public virtual void UpdateStatus(TaskStatus nextStatus)
{
// implementation
}
}
Services in the mod should then be generic and work with the model registered by the host application:
public interface ITaskInteractor<TTask> where TTask : TaskBase
{
Task<TTask> CreateAsync(...);
Task<TTask> ByIdAsync(Guid id);
...
}
The host application can then declare its own model, inherit from the mod base model, and add product-specific fields.
Mod-Owned Model
Use this approach when the mod's domain model is well-defined and extensive host-side model customization is not justified.
Billing is a good example: invoices, payments, credits, charges, and related concepts have a clear domain shape, so the mod can declare and own its models directly.
Best Practices
Fire Events
A mod should publish domain events at the end of each meaningful model action, even when no current consumer needs them. This allows other modules or the host application to react to mod changes without changing the mod.
For example, after creating a task, the mod can publish a TaskCreated event. Other modules can listen to this event and react independently.
Events should contain enough information to understand both the affected model and the specific change that happened. A generic <ModelName>UpdatedEvent with the full final model state may be useful, but it is often not enough. If consumers need to react specifically to a status change or another property-level transition, the event should make that change explicit.
Use String References for External Entities
When a mod needs to reference entities that are outside its bounded context, prefer a string property and an nvarchar(250) database column.
There are two reasons for this:
- The mod does not know the primary key type used by the external entity.
- A string reference can point to different entity types depending on the use case.
For example, provider://<provider id> can reference a medical provider, while patient://<patient id> can reference a patient. Both can be stored in the same ReferenceId property.
Use Mod-Specific Naming
Names inside a mod should come from the mod's own bounded context, not from the host application's business domain.
For example, even if the host application assigns tasks to patients and providers, the tasks mod should avoid terms such as Patient and Provider unless those terms are native to the task domain. Prefer task-specific terminology such as Assignee, Assigner, Reporter, Participant, or similar concepts.
This keeps the tasks mod reusable in applications that do not have patients or medical providers.
Field Map
Each model should have a Field Map that maps textual field keys to model field declarations.
The Field Map in a mod is no different from a Field Map in the main application. It is used by query/filter/sort infrastructure and should be defined for every model that participates in query operations.
Repositories
Repository interfaces are declared in the same package as the models, for example StarterKit.Mod.Billing. They represent the data-access contract required by the mod.
Repository implementations live in the infrastructure package, for example StarterKit.Mod.Billing.Infrastructure.
A typical repository interface looks like this:
public interface IModelRepository
{
Task<IReadOnlyCollection<Model>> ByIdAsync(IEnumerable<Guid> ids);
Task<ListSegment<Model>> ListSegmentAsync(Query? query = null, JsonElement? extra = null);
Task<IReadOnlyCollection<Model>> AddRangeAsync(IEnumerable<Model> models);
Task<IReadOnlyCollection<Model>> DeleteRangeAsync(IEnumerable<Model> models);
}
The repository implementation does not depend directly on a concrete DbContext type. However, it still needs access to the host application's DbContext so mod changes can participate in the same transaction as the rest of the host application.
For this reason, mods use a DbContext provider pattern. The repository injects a provider, and the host application registers the concrete DbContext type through mod configuration.
internal interface IModNameDbContextProvider
{
DbContext Value { get; }
}
internal class ModNameDbContextProvider<TDbContext>(TDbContext dbContext) : IModNameDbContextProvider
{
DbContext Value => dbContext;
}
public static class ModNameConfigurationExtensions
{
public static ModNameConfiguration UseDbContext<TDbContext>(this ModNameConfiguration configuration)
where TDbContext : DbContext
{
configuration.Services.AddScoped<IModNameDbContextProvider, ModNameDbContextProvider<TDbContext>>();
}
}
Best Practices
Add Extra Methods
It is useful to add repository methods even when the mod does not use them directly. The host application may need them for product-specific business logic.
Task<bool> AnyAsync(FilterRule? filterRule = null, JsonElement? extra = null);
Task<int> CountAsync(FilterRule? filterRule = null, JsonElement? extra = null);
Other methods such as FirstOrDefault, SingleOrDefault, and GetAllAsync can usually be implemented as extensions over ListSegmentAsync.
Support the extra Argument
Many repository and interactor methods accept JsonElement? extra. This argument is used when a query needs host-specific data that the mod itself does not understand.
For example, a host application may need to join additional tables or apply extra filtering through a repository decorator or a derived repository implementation.
Interactors
An interactor is a service that represents user-facing entry points into a mod. Each interactor method should correspond to a concrete action that a user or external system can perform.
For example, a tasks mod may expose interactors for creating a task, loading a task, changing task status, or listing tasks.
Interactors have two important properties:
- They describe every supported user interaction with the mod.
- Each interactor method is its own transaction unit.
This makes interactors the main extension point for decorators. The host application can add custom validation, authorization, pre-handlers, and post-handlers around each action without changing the mod.
For example, a pre-handler can check whether the current user is allowed to create tasks. A post-handler can send a notification after a task is created. In many cases a domain event such as TaskCreated is the better option, but a decorator is useful when the reaction should apply only to a specific interaction, such as a manually created task and not a system-assigned task.
When designing an interactor, remember that the mod exists in its own isolation. The mod must not include host-specific authorization rules, because it is not realistic to create one authorization configuration that works for every product installation.
The only runtime-context values the mod may depend on are values that are part of the mod's own execution model: for example the current user id, current UTC time, and, when the mod supports multi-tenancy, the tenant id. In that case, declare an I<ModName>Context abstraction and provide it through DI.
public interface IModNameContext
{
string Id { get; }
string TenantId { get; }
DateTimeOffset UtcNow { get; }
}
The context must stay small. It should not become an authorization service, a host-domain gateway, or a generic dependency container. Authorization still belongs to host-side decorators, and host-domain data should be passed through explicit ports or through decorator logic.
A typical interactor looks like this:
public interface IModelInteractor
{
Task<Model> CreateAsync(CreateModelInput input);
Task<Model> UpdateAsync(UpdateModelInput input);
Task<Model> ByIdAsync(Guid id);
Task<ListSegment<Model>> ListSegmentAsync(Query? query = null, JsonElement? extra = null);
...
}
Best Practices
Support the extra Argument
In the example above, ListSegmentAsync accepts extra. This allows the host application to pass additional data that the mod does not know about but that decorators may need.
For example, in the tasks mod, the host can pass specialtyId through extra. A decorator can resolve it to a concrete list of providers and convert it into an assigneeId filter.
public class TaskInteractorExtensionDecorator : ITaskInteractor
{
// ...
public async Task<ListSegment<Task>> ListSegmentAsync(Query? query = null, JsonElement? extra = null)
{
var specialtyId = extra?.GetProperty("specialtyId").GetString();
if (specialtyId != null)
{
var matchProviderIds = await _providerRepository.GetIdBySpecialtyIdAsync(Guid.Parse(specialtyId));
query = query.WithDefault(FilterRule.In("assigneeId", matchProviderIds));
}
return await _next.ListSegmentAsync(query, extra);
}
}
Authorization Decorator Example
The host application should implement authorization by decorating mod interactors.
// StarterKit.App.Main.Interactors/<ModelNamePlural>/<ModelName>InteractorAuthorizationDecorator
public class ModelInteractorAuthorizationDecorator : IModelInteractor
{
private readonly IModelInteractor _next;
private readonly IAuth _auth;
private readonly IAskyFieldMap<Model> _fieldMap;
private FilterRule DefaultFilterRule
{
get
{
_auth.ValidateEmployee();
return _auth.IsAdmin()
? FilterRule.Eq("organizationId", _auth.OrganizationId)
: FilterRule.Eq("createdById", _auth.UserId);
}
}
public ModelInteractorAuthorizationDecorator(
IModelInteractor next,
IAuth auth,
IAskyFieldMap<Model> fieldMap)
{
_next = next;
_auth = auth;
_fieldMap = fieldMap;
}
public async Task<Model> CreateAsync(CreateModelInput input)
{
_auth.ValidateAdmin();
return await _next.CreateAsync(input);
}
public async Task<Model> ByIdAsync(Guid id)
{
var model = await _next.ByIdAsync(id);
if (!DefaultFilterRule.Compile(_fieldMap).Invoke(model))
throw new UnauthorizedAccessException();
return model;
}
public async Task<ListSegment<Model>> ListSegmentAsync(Query? query = null, JsonElement? extra = null)
{
query = query.WithDefault(DefaultFilterRule);
return await _next.ListSegmentAsync(query, extra);
}
// Repeat the same pattern for the remaining methods.
}
Register Services and Settings
To register mod services and configuration, add an IServiceCollection extension and a mod configuration class in the mod core project. This configuration object is also the composition point used by infrastructure and optional addon packages.
// StarterKit.Mod.ModName/ModNameServiceCollectionExtensions.cs
public static class ModNameServiceCollectionExtensions
{
public static IServiceCollection AddModName(
this IServiceCollection services,
Action<ModNameConfiguration>? configure = null)
{
var configuration = ModNameConfiguration.GetOrCreate(services);
configure?.Invoke(configuration);
return services;
}
}
// StarterKit.Mod.ModName/ModNameConfiguration.cs
public class ModNameConfiguration : IModNameSettings
{
public IServiceCollection Services { get; }
public IDictionary<string, object?> Values { get; } = new Dictionary<string, object?>();
public string SomeModSetting { get; private set; } = "default";
private ModNameConfiguration(IServiceCollection services)
{
Services = services;
services.AddSingleton(this);
services.AddSingleton<IModSettings>(this);
services.AddScoped<IModel1Interactor, Model1Interactor>();
services.AddScoped<IModel2Interactor, Model2Interactor>();
services.TryAddSingleton<IAskyFieldMap<Model1>, Model1AskyFieldMap>();
services.TryAddSingleton<IAskyFieldMap<Model2>, Model2AskyFieldMap>();
services.TryAddScoped<IModel1Service, Model1Service>();
}
public ModNameConfiguration UseSomeModSetting(string value)
{
SomeModSetting = value ?? throw new ArgumentNullException(nameof(value));
return this;
}
internal static ModNameConfiguration GetOrCreate(IServiceCollection services)
{
var instance = (ModNameConfiguration?)services
.FirstOrDefault(x => x.ServiceType == typeof(ModNameConfiguration))
?.ImplementationInstance;
return instance ?? new ModNameConfiguration(services);
}
}
The configuration is registered as a singleton. This allows AddModName to be called multiple times from different packages. This is required when infrastructure settings and optional addon-package settings should remain isolated but still contribute to the same mod configuration.
The configuration can also act as the mod settings object. This is usually done through post-configuration calls, for example:
services.AddModName(x => x.UseSomeModSetting("value"));
Best Practices
Expose Services Through Configuration Extensions
With the configuration object in place, additional packages should usually expose extension methods over ModNameConfiguration, not directly over IServiceCollection.
Examples include UseDbContext or addon registration methods such as billing integrations with Stripe.
services.AddModName(x => x
.UseDbContext<AppDbContext>()
.AddStripe(apiKey, baseUrl));
Expose Shared Values
The Values dictionary allows multiple configuration calls to pass internal settings between packages. One common example is storing the selected DbContext type so infrastructure and addon packages can use the same host-provided context.
Model Builder Extensions
To configure mod models in Entity Framework, create ModelBuilder extension methods that register and configure the mod entities in the host application's DbContext.
Create these extensions under Mod.Infrastructure/Models. Usually there is one extension per model and one extension for the whole mod when the mod contains two or more models.
StarterKit supports EF Core migrations, so ModelBuilder extensions must be well-formed and follow the same rules as the main application. Migrations are generated from these model configurations.
It is a good practice to add an optional post-configuration callback:
Action<EntityTypeBuilder<ModelName>>? postConfigure = null
This allows the mod consumer to add or override configuration, for example by adding .UseActivity(true).
API
Mods follow the same API convention as the main application:
- REST is used for mutations.
- GraphQL is used for reading data.
REST
To add REST endpoints, create a route-builder extension in the API package.
// StarterKit.Mod.ModName.Api/ModelNameRouteBuilderExtensions.cs
public static class ModelNameEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapModelNameApi(this IEndpointRouteBuilder endpoints)
{
var modelNameApi = endpoints.MapGroup("/api/<model-name>")
.WithTags("<ModelName>")
.WithOpenApi();
modelNameApi.MapPost(
"/",
async ([FromServices] IModelNameInteractor interactor, [FromBody] CreateModelNameInput input) =>
{
var id = await interactor.AddAsync(input);
return Results.Ok(id);
})
.WithName("Add<ModelName>");
modelNameApi.MapPut(
"/{id}",
async (
[FromServices] IModelNameInteractor interactor,
[FromRoute] string id,
[FromBody] UpdateModelNameInput input) =>
{
await interactor.UpdateAsync(id, input);
})
.WithName("Update<ModelName>");
// ... other endpoints ...
return endpoints;
}
}
If a mod contains more than one model, add a grouped registration extension so consumers do not have to discover and register every endpoint manually.
public static class ModNameEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapModNameApi(this IEndpointRouteBuilder endpoints)
{
return endpoints
.MapModel1NameApi()
.MapModel2NameApi();
}
}
The host application registers the mod API during startup:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
// ...
endpoints.MapModNameApi();
});
GraphQL
Hot Chocolate allows mods to define GraphQL types without requiring the mod to know every field the host application may later need. The host can extend mod return types by using ObjectTypeExtension<ModelName>.
GraphQL types, query extensions, and data loaders usually live under:
StarterKit.Mod.ModName.Api/HotChocolate
Writing GraphQL types for a mod is mostly the same as writing GraphQL types in the main application. The main difference is that query registration in mods uses ObjectTypeExtension with descriptor.Name("Query") instead of the generic ObjectTypeExtension<T> syntax.
// StarterKit.Mod.ModName.Api/HotChocolate/ModelGraphQL
public class ModelNameGraphQL : ObjectType<ModelName>
{
protected override void Configure(IObjectTypeDescriptor<ModelName> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Name("ModelName");
descriptor.Field(x => x.Id).ID().IsProjected();
descriptor.Field(x => x.Status).Type<TaskStatusGraphQlEnum>();
// ...
base.Configure(descriptor);
}
}
// StarterKit.Mod.ModName.Api/HotChocolate/ModelQueryGraphQLExtension
public class ModelNameQueryGraphQLExtension : ObjectTypeExtension
{
protected override void Configure(IObjectTypeDescriptor descriptor)
{
// Query extensions in mods use ObjectTypeExtension + descriptor.Name("Query")
// to add fields to the root Query type.
descriptor.Name("Query");
descriptor.Field("modelNameListSegment")
.Type<NonNullType<ListSegmentType<ModelName, ModelNameGraphQL>>>()
.Argument("query", x => x.Type<JsonType>())
.Resolve(
async x =>
{
var query = Query.FromJson(x.ArgumentValue<JsonElement?>("query"), x.Service<IAskyFieldMap<ModelName>>());
return await x.Service<IModelNameInteractor>().ListSegmentAsync(query);
});
descriptor.Field("modelName")
.Type<ModelNameGraphQL>()
.Argument("id", x => x.Type<NonNullType<StringType>>())
.Resolve(async x => await x.Service<IModelNameInteractor>().ByIdAsync(x.ArgumentValue<string>("id")));
// ...
base.Configure(descriptor);
}
}
Then create a registration extension to make GraphQL setup simple for mod consumers.
public static class ModNameRequestExecutorBuilderExtensions
{
public static IRequestExecutorBuilder AddModNameTypes(
this IRequestExecutorBuilder builder)
{
builder
.AddType<StatusGraphQLEnum>();
// ...
builder
.AddType<Model1GraphQL>()
.AddType<Model2GraphQL>()
// ...
.AddTypeExtension<Model1QueryGraphQLExtension>()
.AddTypeExtension<Model2QueryGraphQLExtension>()
// ...
.AddDataLoader<Model1DataLoader>()
.AddDataLoader<Model2DataLoader>();
// ...
return builder;
}
}
These types are registered in:
app-main/StarterKit.App.Main.Api/GraphQL/AppApiGraphQLModule.cs
Extend a Mod GraphQL Type
It is common to extend a mod's GraphQL schema with additional objects.
For example, a mod can store an assigneeId but know nothing about the assignee's structure or source. The host application can extend TaskGraphQL and add the assignee field without changing the mod.
Use GraphQL Type Extensions for this.
// StarterKit.App.Main.Api/GraphQL/<ModName>/<ModelName>GraphQLExtension
public class ModelNameGraphQLExtension : ObjectTypeExtension<ModelName>
{
protected override void Configure(IObjectTypeDescriptor<ModelName> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.GuardModelNameAccess(x => x.TenantId);
descriptor.Field(x => x.TenantId).IsProjected();
descriptor.Field(x => x.CreatedById).IsProjected();
descriptor.Field("createdBy").Type<NonNullType<UserGraphQL>>().Resolve(async x => await x
.DataLoader<UserGraphQLDataLoader>()
.LoadAsync(Guid.Parse(x.Parent<ModelName>().CreatedById)));
descriptor.Field("tenant")
.Type<NonNullType<OrganizationGraphQL>>()
.Resolve(async x => await x.DataLoader<OrganizationGraphQLDataLoader>()
.LoadAsync(Guid.Parse(x.Parent<ModelName>().TenantId)));
}
}