Skip to content

First steps

Mirko Da Corte edited this page Aug 25, 2026 · 24 revisions

This guide builds a small ASP.NET Core app end to end — a register of cats — to show how the pieces of Scrinium fit together: domain models, model maps, a DbContext, DI registration, and basic CRUD. The complete project is samples/AspNetCoreSample.

Prerequisites

  • .NET 8, 9 or 10 SDK.
  • A local MongoDB instance reachable at its default address. Scrinium connects there unless you configure a connection string (see Startup and configuration).

1. Create the project

Start from the ASP.NET Core Razor Pages template ("Web Application"), then install the meta package Etherna.Scrinium, which brings in ASP.NET Core and Hangfire integration together:

PM> Install-Package Etherna.Scrinium

Add Etherna.Scrinium.AspNetCore.UI too if you want the optional Admin dashboard. See Packages and feeds for the full package map.

2. Define the domain models

Scrinium works with domain models: entities with an identity and relations expressed by composition — a great fit for DDD. There are two kinds:

  • Entity — has an identity (an Id) and can be mutated by methods. Implements IEntityModel<TKey>.
  • Value object — no identity, immutable after construction, lives only inside an entity or another value object. Implements IModel.

It's good practice to define two abstract base types and derive your domain from them. IModel only requires the MongoDB ExtraElements bag:

using Etherna.Scrinium.Core.Domain.Models;

public abstract class ModelBase : IModel
{
    public virtual IDictionary<string, object> ExtraElements { get; protected set; }
}
using Etherna.Scrinium.Core.Domain.Models;

public abstract class EntityModelBase<TKey> : ModelBase, IEntityModel<TKey>
{
    public virtual TKey Id { get; protected set; } = default!;
}

Now the actual model:

public class Cat : EntityModelBase<string>
{
    public Cat(string name, DateTime birthday)
    {
        Name = name;
        Birthday = birthday;
    }
    protected Cat() { }

    public virtual int Age => (int)((DateTime.Now - Birthday).TotalDays / 365);
    public virtual DateTime Birthday { get; protected set; }
    public virtual string Name { get; protected set; }
}

A few rules are at play here — they apply to every persisted model (full list in Domain models):

Model rules

  • Everything is virtual. Scrinium subclasses your models with a proxy generated at compile time (a source generator shipped inside the core package) to provide lazy loading and change tracking; non-virtual members can't be overridden.
  • A protected parameterless constructor is required so the serializer can materialize an empty instance and fill it. Keep your public constructor for valid application-side creation.
  • Writable members need a setter, which may be non-public (protected here). Members with no setter — like Age — are computed and ignored by the serializer by default.
  • Id is left unset: the id generator configured in the model map (step 3 below) assigns it on insert (via reflection, so a non-public setter is fine).

3. Map the models

A model map tells the serializer how a model is written to and read from documents. You register maps by implementing IModelMapsCollector and calling MapRegistry.AddModelMap<TModel>(...). The first argument is the map's schema id: an immutable string stamped into each document to record which map wrote it, letting several schema versions coexist in one collection (see Versioned schemas). Any non-empty string works; GUIDs are a convenient choice.

using Etherna.MongoDB.Bson;
using Etherna.MongoDB.Bson.Serialization.IdGenerators;
using Etherna.MongoDB.Bson.Serialization.Serializers;
using Etherna.Scrinium.Core;
using Etherna.Scrinium.Core.Serialization;

class ModelBaseMap : IModelMapsCollector
{
    public void Register(IDbContextEngine dbContextEngine)
    {
        dbContextEngine.MapRegistry.AddModelMap<ModelBase>("1252861f-82d9-4c72-975e-3571d5e1b6e6");

        dbContextEngine.MapRegistry.AddModelMap<EntityModelBase<string>>(
            "81dd8b35-a0af-44d9-80b4-ab7ae9844eb5",
            schema =>
            {
                schema.AutoMap();

                schema.IdMemberMap.SetSerializer(new StringSerializer(BsonType.ObjectId))
                                  .SetIdGenerator(new StringObjectIdGenerator());
            });
    }
}
using Etherna.Scrinium.Core;
using Etherna.Scrinium.Core.Serialization;

class CatMap : IModelMapsCollector
{
    public void Register(IDbContextEngine dbContextEngine)
    {
        dbContextEngine.MapRegistry.AddModelMap<Cat>("cd37bafa-a36d-4b1f-815a-deb50c49d030");
    }
}

The optional second argument configures the map. Without it, Scrinium applies AutoMap(). Here the base map calls AutoMap() and then customizes the Id member to serialize a string as a MongoDB ObjectId and generate one on insert. CatMap just auto-maps Cat. Splitting maps into collectors is optional but keeps things tidy. Model mapping is covered in depth in Model mapping.

4. Create the DbContext

A DbContext is a unit of work that exposes the repositories of one domain boundary. Declare an interface (recommended, for DI and testing) and a class deriving from DbContext:

using Etherna.Scrinium.Core;
using Etherna.Scrinium.Core.Repositories;

public interface ISampleDbContext : IDbContext
{
    IRepository<Cat, string> Cats { get; }
}
using Etherna.Scrinium.Core;
using Etherna.Scrinium.Core.Repositories;
using Etherna.Scrinium.Core.Serialization;

public class SampleDbContext : DbContext, ISampleDbContext
{
    public IRepository<Cat, string> Cats { get; } = new Repository<Cat, string>("cats");

    protected override IEnumerable<IModelMapsCollector> ModelMapsCollectors =>
        new IModelMapsCollector[]
        {
            new ModelBaseMap(),
            new CatMap()
        };

    protected override Task SeedAsync()
    {
        // Seed initial data here.
        return base.SeedAsync();
    }
}
  • Cats is a repository over Cat documents in the "cats" collection.
  • ModelMapsCollectors enumerates the collectors from step 3 so their maps get registered.
  • SeedAsync populates the database, once per context across every application instance (see Database seeding) — leave it calling base.SeedAsync() for now.

Note. The DbContext is created and wired by Scrinium — you don't write a constructor that takes dependencies or options. Options (connection string, database name…) are set at registration time; see Startup and configuration.

5. Register everything in Program.cs

Wire Scrinium into DI with AddScriniumWithHangfire() and register the context with AddDbContext. Add the dashboard and Hangfire server, and seed the contexts on startup:

using Etherna.Scrinium.AspNetCore.Extensions;
using Etherna.Scrinium.AspNetCore.UI;      // only if using the dashboard
using Etherna.Scrinium.Extensions;
using Hangfire;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddHangfireServer();

builder.Services.AddScriniumWithHangfire()
    .AddDbContext<ISampleDbContext, SampleDbContext>();

builder.Services.AddScriniumAdminDashboard();   // optional

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();        // required by the dashboard
app.UseHangfireDashboard();
app.MapRazorPages();

app.SeedDbContexts();          // run each context's SeedAsync

app.Run();

Note. Registration variants (including composing Etherna.Scrinium.AspNetCore with a non-Hangfire task runner) and every option are documented in Startup and configuration.

6. Use it

Inject the context interface and call the repository. This Razor Page adds, lists and removes cats:

using Etherna.MongoDB.Driver.Linq;   // ToListAsync on the LINQ query
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;

public class IndexModel : PageModel
{
    public class InputModel
    {
        [Required]
        [DataType(DataType.Date)]
        public DateTime Birthday { get; set; }

        [Required]
        public string Name { get; set; } = default!;
    }

    private readonly ISampleDbContext sampleDbContext;

    public IndexModel(ISampleDbContext sampleDbContext) =>
        this.sampleDbContext = sampleDbContext;

    public List<Cat> Cats { get; } = new();

    [BindProperty]
    public InputModel Input { get; set; } = default!;

    public async Task<IActionResult> OnGetAsync()
    {
        await LoadCatsAsync();
        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        await LoadCatsAsync();
        if (!ModelState.IsValid)
            return Page();

        var cat = new Cat(Input.Name, Input.Birthday);
        await sampleDbContext.Cats.CreateAsync(cat);
        return RedirectToPage();
    }

    public async Task<IActionResult> OnPostRemoveAsync(string id)
    {
        await sampleDbContext.Cats.DeleteAsync(id);
        return RedirectToPage();
    }

    private async Task LoadCatsAsync()
    {
        var cats = await sampleDbContext.Cats.QueryElementsAsync(elements =>
            elements.ToListAsync());
        Cats.AddRange(cats);
    }
}

That's the full loop: CreateAsync inserts the document (generating its Id), DeleteAsync removes it, and QueryElementsAsync runs a LINQ query over the collection — the complete repository API is in CRUD operations and Querying.

Result

Next steps

Clone this wiki locally