Notes example in .NET
Notes is a unit with one operation that creates a note. It demonstrates a manifest, exposition, domain configuration, a handler and event publication. These are the actual example files used in the verified build.
Verified: a clean project compiled with published SeeraOS.Unit.SDK.AspNetCore 1.12.0, targeting net10.0. Not tested: this tutorial’s runtime with the platform or acceptance by an independent developer. Notes are stored in memory and disappear on restart.
Prepare the project
You need .NET 10 and your own private feed access. The operator supplies the address and permissions. Keep credentials out of the project and URL. See package access.
Create a Notes directory containing these files. Paths are relative to that directory. This manually written example does not require Ontology or DSL generation.
Notes.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SeeraOS.Unit.Sample.Notes</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SeeraOS.Unit.SDK.AspNetCore" Version="1.12.0" />
<EmbeddedResource Include="Expositions/note.v1.json" />
</ItemGroup>
</Project>
Unit files
Program.cs connects the host to the SDK and registers your components. NoteManifest.cs declares a single tenant and the greeting field. NoteExpositions.cs serves the exact note.v1 document. CreateNote.cs implements the operation. NoteStore.cs stores notes in memory.
Program.cs
using SeeraOS.Unit.Sample.Notes;
using SeeraOS.Unit.SDK;
using SeeraOS.Unit.SDK.Configuration;
using SeeraOS.Unit.SDK.Expositions;
using SeeraOS.Unit.SDK.Manifest;
using SeeraOS.Unit.SDK.Serving;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Everything platform-facing: enrolling, serving the exposition parity endpoint, receiving
// configuration, subscribing to lifecycle commands, reporting liveness.
builder.Services.AddSeeraOSUnit(builder.Configuration);
// Who is calling, verified against the deployment's issuer at SeeraOS:Unit:Auth. Without it every
// call naming a tenant is refused as unauthenticated, however good its token.
builder.Services.AddSeeraOSUnitAuthentication(builder.Configuration);
// This unit's own parts.
builder.Services.AddSingleton<NoteStore>();
builder.Services.AddSingleton<IExpositionDocumentSource, NoteExpositions>();
builder.Services.AddSingleton<IUnitManifestSource, NoteManifest>();
builder.Services.AddSingleton<IUnitConfigurationHandler, NoteConfiguration>();
builder.Services.AddSingleton<IUnitOperationHandler, CreateNote>();
WebApplication app = builder.Build();
app.UseAuthentication();
app.MapSeeraOSUnit();
await app.RunAsync();
NoteManifest.cs
using SeeraOS.Unit.SDK.Manifest;
namespace SeeraOS.Unit.Sample.Notes;
/// <summary>
/// What this unit declares about itself: one tenant per process, and the configuration it takes.
/// </summary>
/// <remarks>
/// The configuration fields are declared here and nowhere else. A value the platform delivers that
/// this list does not name fails the apply before <see cref="NoteConfiguration"/> sees it, so a field
/// the handler reads has to be in this list.
/// </remarks>
public sealed class NoteManifest : IUnitManifestSource
{
private static readonly UnitManifest _manifest = new()
{
TenantAffinity = TenantAffinity.SingleTenant,
InstancesPerTenant = InstancesPerTenant.One,
Config =
[
new ConfigFieldDeclaration
{
Name = "greeting",
Class = ConfigFieldClass.NonSensitive,
Type = "string",
Required = true,
},
],
};
public ValueTask<UnitManifest> GetManifestAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(_manifest);
}
}
NoteExpositions.cs
using System.Reflection;
using SeeraOS.Unit.SDK.Expositions;
namespace SeeraOS.Unit.Sample.Notes;
/// <summary>
/// Serves the exposition document this release published.
/// </summary>
/// <remarks>
/// The document is an artefact of the build, embedded and handed over as its exact bytes. Nothing
/// here re-derives it from the running code: the process is checked against the document, not the
/// other way round.
/// </remarks>
public sealed class NoteExpositions : IExpositionDocumentSource
{
public async ValueTask<IReadOnlyList<ExpositionDocument>> GetDocumentsAsync(CancellationToken cancellationToken = default)
{
await using Stream stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("SeeraOS.Unit.Sample.Notes.Expositions.note.v1.json")!;
await using MemoryStream buffer = new();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return [new ExpositionDocument("note.v1", buffer.ToArray())];
}
}
NoteConfiguration.cs
using System.Text.Json;
using SeeraOS.Unit.SDK.Configuration;
namespace SeeraOS.Unit.Sample.Notes;
/// <summary>
/// Takes the configuration the platform delivers.
/// </summary>
/// <remarks>
/// Fetching it, verifying its digest and unsealing it have already happened. What is left is
/// whether this unit can use the content — and saying no is a real answer, reported as a failure
/// the platform will not retry.
/// </remarks>
public sealed class NoteConfiguration(ILogger<NoteConfiguration> logger) : IUnitConfigurationHandler
{
public string? Greeting { get; private set; }
public ValueTask<bool> ApplyAsync(UnitConfiguration configuration, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.NonSensitive.ValueKind != JsonValueKind.Object
|| !configuration.NonSensitive.TryGetProperty("greeting", out JsonElement greeting))
{
logger.LogError("Configuration version {Version} carries no greeting.", configuration.Version);
return ValueTask.FromResult(false);
}
Greeting = greeting.GetString();
return ValueTask.FromResult(true);
}
}
NoteStore.cs
using System.Collections.Concurrent;
namespace SeeraOS.Unit.Sample.Notes;
/// <summary>The unit's business state. Nothing platform-shaped lives here.</summary>
public sealed class NoteStore
{
private readonly ConcurrentDictionary<string, Note> _notes = new(StringComparer.Ordinal);
public Note Add(string id, string text, string author)
{
Note note = new(id, text, author);
_notes[id] = note;
return note;
}
public int Count => _notes.Count;
}
/// <summary>A note.</summary>
public sealed record Note(string Id, string Text, string Author);
CreateNote.cs
using System.Text.Json;
using SeeraOS.Unit.SDK.Errors;
using SeeraOS.Unit.SDK.Messaging;
using SeeraOS.Unit.SDK.Serving;
using SeeraOS.Unit.SDK.Wire;
namespace SeeraOS.Unit.Sample.Notes;
/// <summary>
/// Creates a note and announces it.
/// </summary>
/// <remarks>
/// The whole of this class is the unit's own business: reading its input, refusing what it will not
/// accept, storing the result and saying what happened. Who called, which tenant they act for and
/// whether they may — decided before this runs.
/// </remarks>
public sealed class CreateNote(NoteStore notes, IUnitEventPublisher publisher, TimeProvider timeProvider)
: IUnitOperationHandler
{
public string Entity => "note";
public string Operation => "create";
public async ValueTask<UnitOperationResult> HandleAsync(
UnitOperationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!request.Body.TryGetProperty("text", out JsonElement text)
|| text.GetString() is not { Length: > 0 } content)
{
return UnitOperationResult.Failed(
UnitError.Validation("Note.TextRequired", "A note carries text.")
.WithRemediation("Send a non-empty 'text'."));
}
Note note = notes.Add(UlidScheme.New(timeProvider), content, request.Caller?.UserName ?? "anonymous");
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(new
{
id = note.Id,
text = note.Text,
author = note.Author,
}));
await publisher.PublishAsync(
new UnitEvent
{
LogicalType = "seeraos.notes.note.created.v1",
Entity = Entity,
Operation = "created",
MessageType = "add",
EntityId = note.Id,
// The causal link to the call that produced this. The caller's identity is not in
// the payload, and does not belong there.
CorrelationId = request.CorrelationId,
Data = payload.RootElement.Clone(),
},
cancellationToken).ConfigureAwait(false);
return UnitOperationResult.Ok(payload.RootElement.Clone());
}
}
Expositions/note.v1.json
{
"contract": "note.v1",
"unit": "notes",
"objects": {
"Note": {
"identity": "id",
"fields": {
"id": { "type": "string" },
"text": { "type": "string" },
"author": { "type": "string" }
}
}
},
"operations": {
"note.create": {
"object": "Note",
"verb": "create",
"request": "Note",
"response": "Note"
}
},
"events": {
"note.created": {
"object": "Note",
"payload": "Note"
}
}
}
Compile
After the operator configures the feed:
dotnet restore Notes.csproj
dotnet build Notes.csproj --no-restore
The verified build with these files and package 1.12.0 ended in BUILD_PASS. Do not automatically pair SDK 1.12.0 with any toolkit: verify compatibility of the specific version set separately.
What the operation does
Example operation body:
{"text":"My first note"}
The handler rejects missing or empty text with Note.TextRequired. The published event has logical type seeraos.notes.note.created.v1. The result contains the created id, text and author.
This is an operation body, rather than complete HTTP calling instructions. The address and headers depend on the API and permissions of the accepted environment.
Before the first run
Agree with the operator on a compatible version set, tenant and permissions, CoreAddress, LinkKey, preserved identity storage, Auth profiles and transports. The operator must also supply the required greeting domain configuration. See unit lifecycle.
Environment address, account provisioning and an agreed complete test call:
(information to be provided)
Once that configuration is available, start the host:
dotnet run --project Notes.csproj
Runtime: NOT_RUN. Acceptance requires performing note.create, checking its response and observing the event in the agreed environment. A running process alone does not satisfy this criterion.