Skip to content

Architecture ​

Callora is deliberately modeled on Shopware and Symfony: a thin platform kernel that owns cross-cutting concerns, and plugins that carry all domain logic and wire themselves in code-first (ADR-009). This page explains the platform model, the runtime that loads plugins, and the governance boundary that keeps a broadly visible core from becoming an unmanageable contract.

Host versus plugins ​

The host (src/Host, on top of src/Core) is a pure platform:

  • Authentication and RBAC (SuperAdmin is global; Admin is scoped per workspace).
  • User and plugin management, including the operator lifecycle API.
  • The business-event bus and the background job queue.
  • The dynamic plugin-routing surface.

It contains no domain logic. Voice, dialing, contact-center flows, themes — every domain-specific concern — lives in a plugin under custom/plugins (application plugins) or custom/static-plugins (bundled system plugins such as Communication).

This split is ADR-007. The practical consequence for you: you never edit the core to add a feature. You write a plugin and attach it through one of the documented mechanisms.

The three axes ​

Callora separates three orthogonal concepts. Conflating them is the most common early modeling mistake.

AxisQuestion it answersRough analogue
TenantWho pays / who is billed?The customer account
WorkspaceWhich logical system and data set?A Shopware sales channel's data
SurfaceWhich access point, and which page within it?A sales channel, plus its category tree
  • Data shared across access points → one workspace, several surfaces.
  • Systems that must stay isolated → several workspaces.

Surfaces form a tree (ADR-019). A node without a parent is an application root and carries the access — host, access mode, theme, identity provider. A node with a parent is a page: it inherits all of that and overrides only what it needs. Every node may carry a layout, which is what makes a website with several pages possible without several access channels.

Inheritance and navigation end at the next root: two nodes under one root are the same application, two under different roots are not. Only a root assigns an identity provider, so the session boundary coincides with the application boundary rather than sitting somewhere inside a tree.

A workspace is, by definition, not a front-end. The tenant-facing rendering layer is the Surface runtime (ADR-015), which is why the former Callora.Workspace API package is being renamed to Callora.Surface. Business events and plugin routes are scoped by WorkspaceKey throughout.

The ALC-based plugin runtime ​

Every plugin loads into its own collectible AssemblyLoadContext (ALC), so it can be unloaded and hot-swapped without restarting the host.

  • PluginAssemblyLoadContext (src/Core/Application/Plugins/PluginAssemblyLoadContext.cs) extends AssemblyLoadContext with isCollectible: true. Its Load override resolves in three steps: the shared contract registry first (a contract another plugin declared — first, because it is the only step that enforces major-version compatibility), then whatever the host process already provides (it owns that assembly; the plugin gets the host's copy, which is how Callora.Core keeps its shared type identity), and only then plugin-local via AssemblyDependencyResolver.

    Until August 2026 the first step was a name check instead: Callora and Callora.* returned null and came from the default context. That was right about platform assemblies and wrong about the namespace — internal plugins carry the prefix too (ADR-025), so a contract assembly such a plugin brought along was sent to a context that did not have it, and the first access to one of its types threw. Asking the process is the same question without a name list to maintain.

  • RuntimePluginHost (ICalloraPluginRuntime, in src/Core/Application/Plugins/RuntimePluginHost.cs) owns install / activate / deactivate / uninstall, guarding mutations with a SemaphoreSlim.

  • Discovery is one-shot and recursive at startup.LocalPluginDiscoveryService (src/Core/Infrastructure/Startup/) walks the plugin roots for registry.json files and reconciles them against the database. There is noFileSystemWatcher; the database is the source of truth for installed / active state.

  • Unload is GC-verified. On deactivation the host calls the plugin's StopAsync, drops its exports, calls AssemblyLoadContext.Unload(), and then waits (via a WeakReference) for the ALC to be collected. If the context stays pinned, the plugin is marked UnloadFailed rather than silently leaking.

Why the core is shared, not isolated ​

ALCs isolate types and versions, not capabilities. Callora deliberately wants a plugin to be able to decorate and subscribe to a broad surface, and decoration requires type identity: a plugin can only decorate a service whose type it can reference. So Callora.Core (plus Administration and Surface) is loaded once into the shared default context and never duplicated into plugin ALCs. Third-party contract assemblies that must be shared across plugins are unified through SharedContractAssemblyRegistry (ADR-012, PLAT-256): they load into the default context so host and plugins see the same System.Type instances.

The governance boundary ​

A single, broadly visible core is powerful but dangerous: every public signature risks becoming a de-facto contract. Callora manages this with three visibility tiers and a set of compile-time guards — the .NET equivalent of Shopware's PHPStan @internal / @final / BC-checker.

The three tiers (ADR-012 §2.2) ​

TierMeaningMarker
VisibleReferenceable, but not a sanctioned extension point(no marker)
ExtensibleMay be implemented / contributed to / decorated[CalloraExtensible]
ReplaceableMay be replaced entirely, under deterministic precedence[CalloraExtensible(Replaceable)]

Two attributes carry the intent, both in src/Core/Extensibility:

csharp
// An official extension point. Mode says what a plugin may do with it.
[CalloraExtensible(ExtensionPointMode.Decoratable, "Wrap to intercept outbound mail")]
public interface IMailSender { /* ... */ }

// Public for technical reasons only — NOT a plugin contract.
[CalloraInternal("Composition root; construct via the host")]
public sealed class SomeInternalWiring { /* ... */ }

ExtensionPointMode is Contributable (additive, the default), Decoratable (wrap via IServiceDecorator<TService>), or Replaceable. Security-critical host contributors carry [HostProtected] so a plugin export cannot silently supplant them.

The analyzers (src/Analyzers) ​

The Roslyn analyzers run in-build with TreatWarningsAsErrors and EnforceCodeStyleInBuild on (Directory.Build.props). Framework assemblies exempt themselves via the CalloraFrameworkAssembly=true MSBuild property; your plugin does not set it, so all four rules apply to plugin code.

IDRuleSeverity
CAL0001Consuming a [CalloraInternal] API from outside the frameworkError
CAL0002Deriving from / implementing a [CalloraInternal] typeError
CAL0003Missing XML docs on the plugin-contract surface (.Contracts, Extensibility, [CalloraExtensible] members)Error
CAL0004An [ExtensionPointId] argument that does not reference a CalloraExtensionPoints constantError

CAL0004 turns a mistyped extension-point id into a compile error with IDE completion, instead of a runtime activation failure. All four link to ADR-012.

The PublicAPI baseline ​

On top of the Callora analyzers, every framework assembly runs Microsoft's Microsoft.CodeAnalysis.PublicApiAnalyzers with a tracked baseline: PublicAPI.Shipped.txt (released surface) and PublicAPI.Unshipped.txt (surface added since the last release), present in src/Core, src/Administration, src/Workspace, and src/Surface.Rendering. Adding or changing a public signature that is not recorded fails the build (RS0016 / RS0017). This is the mechanism that makes "public core signatures are de-facto contracts" enforceable rather than aspirational. The workflow is in Testing & Publishing.

How this mirrors Shopware / Symfony ​

Shopware / SymfonyCallora
Bundles wire in code, not by manifest endpointsCode-first extension wiring (ADR-009)
Event subscribers, before/after eventsIBusinessEventListener, mutable/cancelable events
Service decoration / DI decorationIServiceDecorator<TService> per-call proxy
PHPStan @internal / @final / BC-checkerCAL0001–CAL0004 + PublicAPI baseline
Trusted, signed marketplace pluginsTrusted-in-process by provenance (ADR-013)
Storefront SSR + progressive enhancementSurface SSR (Nunjucks/Jint) + Vue islands

The guiding principle (ADR-012) is "freedom over protection": maximum extensibility on a broad visible surface, with real internal boundaries reserved for security, compliance, and lifecycle governance.