Aster is a .NET SDK for defining, versioning, and querying composable resources using a Resource → Aspect → Facet model.
It provides a headless, backend-agnostic foundation for attaching reusable, cross-cutting capabilities (Tags, Owner, RBAC, Scheduling, …) to any resource type — without hard-coding every entity from scratch.
Status: Phase 5 in progress — Core SDK, in-memory engine, SQLite JSON persistence/querying, query capability discovery, preflight validation, typed query helpers, provider conformance support, portable operators, SQLite date-like ranges, explicit index projection contracts, definition schema upgrades, portability primitives, host lifecycle hooks, and explicit tenant scoping are available. See Roadmap for future phases.
- Concepts
- Quick Start
- DI Registration
- Querying
- Versioning & Activation
- Typed Aspects & Facets
- Project Structure
- Roadmap
- Contributing
- License
| Term | Description |
|---|---|
| Resource Definition | The schema for a resource type (e.g. Product, WorkflowDefinition). Immutable — each update appends a new definition version. |
| Resource | A versioned instance of a Resource Definition. ResourceId is stable across versions; Id identifies the exact version snapshot. |
| Aspect Definition | A reusable "part" that can be attached to any Resource Definition (e.g. TitleAspect, PriceAspect). |
| Aspect Instance | The per-resource-version data for an attached aspect, stored as a dictionary of facet values. |
| Facet Definition | A typed field declared inside an Aspect Definition (e.g. Title: string, Amount: decimal). |
| Facet Value | The actual value of a facet on an aspect instance. |
| Activation Channel | A named delivery context (e.g. "Published", "Staging"). A resource version becomes active when placed in a channel. Multiple channels and multiple simultaneously active versions are supported. |
| Tenant Scope | An explicit opaque tenant boundary for definitions, resources, activation state, queries, schema upgrades, portability, and lifecycle hooks. Omitted scope resolves to the default single-tenant scope. |
Resources follow an append-only versioning model — versions are never mutated. A version with no activation entry is implicitly a draft.
builder.Services.AddAsterCore();This registers the in-memory store, resource manager, query service, typed aspect/facet binders, and identity generator.
For SQLite-backed persistence and querying, call AddAsterSqliteJson(...) after AddAsterCore():
builder.Services.AddAsterCore();
builder.Services.AddAsterSqliteJson(options =>
{
options.ConnectionString = "Data Source=aster.db";
});using Aster.Core.Definitions;
// Plain C# records become typed aspects
private record TitleAspect(string Title);
private record PriceAspect(decimal Amount, string Currency);
var definition = new ResourceDefinitionBuilder()
.WithDefinitionId("Product")
.WithAspect<TitleAspect>()
.WithAspect<PriceAspect>()
.Build();
await definitionStore.RegisterDefinitionAsync(definition);For tenant-aware hosts, pass a TenantScope explicitly:
var tenant = TenantScope.FromTenantId("tenant-a");
await definitionStore.RegisterDefinitionAsync(definition, tenant, CancellationToken.None);var resource = await manager.CreateAsync("Product", new CreateResourceRequest
{
TenantScope = tenant, // omit for default single-tenant behavior
InitialAspects = new Dictionary<string, object>
{
["TitleAspect"] = new TitleAspect("Super Gadget"),
["PriceAspect"] = new PriceAspect(99.99m, "USD"),
}
});
// resource.Version == 1, no activation entry → implicitly draftvar v2 = await manager.UpdateAsync(resource.ResourceId, new UpdateResourceRequest
{
BaseVersion = resource.Version, // optimistic lock
AspectUpdates = new Dictionary<string, object>
{
["TitleAspect"] = new TitleAspect("Super Gadget Pro"),
}
});
// v2.Version == 2await manager.ActivateAsync(resource.ResourceId, version: 2, channel: "Published");var latest = await manager.GetLatestVersionAsync(resource.ResourceId);
var title = latest!.GetAspect<TitleAspect>("TitleAspect", binder);
Console.WriteLine(title?.Title); // "Super Gadget Pro"AddAsterCore() wires the following services:
| Interface | Default Implementation |
|---|---|
IResourceDefinitionStore |
InMemoryResourceDefinitionStore |
IResourceManager |
DefaultResourceManager |
IResourceVersionWriter |
InMemoryResourceStore |
IResourceVersionReader |
InMemoryResourceStore |
IResourceQueryService |
InMemoryQueryService |
IResourceQueryCapabilitiesProvider |
InMemoryQueryCapabilitiesProvider |
IResourceQueryValidator |
ResourceQueryValidator |
IResourceSchemaVersionService |
ResourceSchemaVersionService |
ITypedAspectBinder |
SystemTextJsonAspectBinder |
ITypedFacetBinder |
SystemTextJsonFacetBinder |
IIdentityGenerator |
GuidIdentityGenerator |
All services are registered as singletons — the in-memory store is the single shared instance within the process.
Use IResourceQueryService with a portable ResourceQuery AST:
var query = new ResourceQuery
{
TenantScope = tenant,
DefinitionId = "Product",
Filter = new FacetValueFilter("TitleAspect", "Title", "Gadget", ComparisonOperator.Contains),
Sorts = [new SortExpression("Created", SortDirection.Descending)],
Skip = 0,
Take = 20,
};
var results = await queryService.QueryAsync(query);The in-memory evaluator supports Equals, Contains, and Range, plus latest/all/active/draft version scopes.
The SQLite JSON provider executes the same ResourceQuery AST in SQLite for its supported subset: metadata filters/sorts, version scopes, paging, aspect presence, scalar facet Equals/Contains, and numeric facet Range. Unsupported provider query shapes throw UnsupportedQueryFeatureException.
Inspect provider support and validate before execution:
var capabilities = serviceProvider.GetRequiredService<IResourceQueryCapabilitiesProvider>().Capabilities;
var validation = serviceProvider.GetRequiredService<IResourceQueryValidator>().Validate(query);Typed helpers build the same portable query model without repeating common aspect/facet strings:
var filter = TypedQuery.For<TitleAspect>()
.Facet(x => x.Title)
.Contains("Gadget");- Immutable versions — every call to
UpdateAsyncappends a new version. - Definition lineage — every new resource records the active
ResourceDefinition.VersioninResource.DefinitionVersion. Normal updates preserve that recorded lineage. - Optimistic concurrency —
UpdateResourceRequest.BaseVersionmust match the current latest; mismatches throwConcurrencyException. - Activation —
ActivateAsync(resourceId, version, channel, allowMultipleActive):allowMultipleActive = false(default): deactivates all other versions in the channel first.allowMultipleActive = true: adds alongside existing active versions.
- Retrieval helpers on
IResourceManager:GetLatestVersionAsync— the most recently created version.GetVersionAsync(resourceId, version)— a specific version snapshot.GetVersionsAsync(resourceId)— all versions.GetActiveVersionsAsync(resourceId, channel)— all active versions in a channel.
Use IResourceSchemaVersionService when a host needs to inspect or explicitly advance definition lineage:
var schemaVersions = serviceProvider.GetRequiredService<IResourceSchemaVersionService>();
var status = await schemaVersions.GetSchemaStatusAsync(resource);
var upgrade = await schemaVersions.UpgradeAsync(resource.ResourceId, new ResourceSchemaUpgradeRequest
{
BaseVersion = resource.Version,
TargetDefinitionVersion = status.LatestDefinitionVersion,
});Schema status is evaluated for one resource version at a time. Explicit upgrades append a new resource version, default to the latest definition version when no target is supplied, preserve existing aspect data by default, and report CarriedForwardAspectKeys for aspect keys not declared by the target definition. A target equal to the source lineage returns ResourceSchemaUpgradeStatus.NoOp; invalid targets throw ResourceSchemaUpgradeException with a stable Code.
| Exception | When thrown |
|---|---|
ConcurrencyException |
BaseVersion mismatch on update or activate |
VersionNotFoundException |
Requested version does not exist |
ResourceSchemaUpgradeException |
Explicit schema upgrade target is invalid or unavailable |
SingletonViolationException |
Creating a second instance of a singleton definition |
DuplicateResourceIdException |
Caller-supplied ResourceId already exists |
DuplicateAspectAttachmentException |
Same aspect key attached twice to a definition |
Any C# class or record can be used as a typed aspect:
record PriceAspect(decimal Amount, string Currency);Read a typed aspect from a resource:
var price = resource.GetAspect<PriceAspect>("PriceAspect", binder);Write a typed aspect (returns a new immutable Resource record — State Replace semantics):
var updated = resource.SetAspect("PriceAspect", new PriceAspect(129.99m, "USD"), binder);The same pattern works at the facet level via AspectInstance.GetFacet<T> and AspectInstance.SetFacet<T>.
The default ITypedAspectBinder implementation uses System.Text.Json.
src/
core/
Aster.Core/ ← Main SDK library (net8.0 / net9.0 / net10.0)
Abstractions/ ← Interfaces (IResourceManager, IResourceDefinitionStore, …)
Definitions/ ← ResourceDefinitionBuilder (fluent API)
Exceptions/ ← Typed exceptions
Extensions/ ← DI helpers, GetAspect / SetAspect extensions
InMemory/ ← In-memory implementations
Models/ ← Domain models (Resource, AspectDefinition, ResourceQuery, …)
Services/ ← SystemTextJson binders, GuidIdentityGenerator
persistence/
Aster.Persistence.SqliteJson/
← SQLite JSON definition/resource version/query provider
apps/
Aster.Web/ ← Workbench / playground (ASP.NET Core minimal API)
test/
Aster.Tests/ ← xUnit tests (unit + integration)
docs/ ← Architecture review, coding conventions, roadmap
specs/ ← Feature specs (001-core-sdk-foundation, …)
| Phase | Title | Status |
|---|---|---|
| 1 | Core SDK & In-Memory Engine | ✅ Complete |
| 2A | SQLite JSON Persistence & Querying | ✅ Complete |
| 3 | Query Capabilities & Typed Querying | ✅ Complete |
| 4 | Portability & Integration Hooks | ✅ Core Complete |
| 5 | Multi-tenancy, Policies, Advanced Versioning | 🚧 In Progress |
| 6 | Operational Hardening (concurrency, perf, migrations) | 📋 Planned |
See docs/Roadmap.md and wiki/Roadmap.md for the full phase breakdown with epics and definitions of done.
Aster.Abstractions
Aster.Definitions
Aster.Runtime
Aster.Querying
Aster.Indexing
Aster.Persistence.<Backend> (e.g., SqliteJson, PostgresJsonb, Mongo)
Aster.Hosting
Aster.Recipes (optional)
Please read docs/coding-conventions.md before submitting a PR.
MIT — see LICENSE.
