Application Structure
Six primitives divide a magic app's write-heavy screens the way Laravel divides a controller-heavy one: each owns exactly one kind of state, and none of them reaches into another's.
- Introduction
- Controllers Own Read State
- Actions Own Writes
- Form Objects Own Form State
- Repositories Own Rows
- SessionScope Resets Every Tenant Holder
- Listeners Replace a Central Realtime Dispatcher
- How the Six Compose
Introduction
A controller that also validates, also writes, also owns a paginated collection, and also resubscribes a broadcast channel is not one class doing six jobs well; it is six responsibilities racing each other on one notifyListeners(). This page names the six pieces a magic app now has for that: MagicController (read state), MagicAction (a write), MagicFormObject (a screen's form), Repository/RepositoryQuery (a resource's rows), SessionScope (the tenant boundary), and BroadcastListeners (the shared channel registry). None of them is new architecture on top of magic; each is the existing primitive from doc/basics/controllers.md, doc/basics/forms.md, doc/eloquent/repositories.md, doc/digging-deeper/session-scope.md, and doc/digging-deeper/broadcasting.md, read together as one map.
Controllers Own Read State
A MagicController answers "what does this screen show right now": RxStatus, a MagicFormObject's field values, a RepositoryQuery's pages. It does not persist a write itself; it runs a MagicAction through RunsActions.runAction and reacts to the result.
class MonitorController extends MagicController
with MagicStateMixin>, RunsActions {
static MonitorController get instance => Magic.findOrPut(MonitorController.new);
Future pause(String id) async {
final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id);
if (!outcome.succeeded) return;
}
}
See doc/basics/controllers.md for MagicStateMixin, fetch helpers, and lifecycle.
Actions Own Writes
A MagicAction is one stateless write, Fortify's Actions\* ported: no MagicController base, no notifyListeners, nothing to dispose. It throws on failure (a ValidationException for a 422 or a failed client rule, anything else for a transport fault) rather than answering a bool, because RunsActions.runAction is the one place both are already handled.
class PauseMonitor extends MagicAction {
const PauseMonitor();
@override
Future handle(String monitorId) async {
final response = await Http.update('monitors', monitorId, {'status': 'paused'});
if (!response.successful) {
throw Exception(response.errorMessage ?? 'Failed to pause monitor');
}
}
}
A test swaps the real action for a fake via MagicAction.bind and clears every override with MagicAction.flush() in tearDown. See doc/basics/actions.md.
Form Objects Own Form State
A MagicFormObject is a Livewire-style Form object: one field-bound, validated, persistable unit for a single screen's write, created once per State and never registered in the container (onClose asserts against it). It composes MagicFormData, ValidatesRequests, and RunsActions rather than hand-wiring all three per screen.
class MonitorFormObject extends MagicFormObject {
MonitorFormObject({this.editing});
final Monitor? editing;
@override
Map get initial => {'name': editing?.name ?? '', 'url': editing?.url ?? ''};
@override
FormRequest get request =>
editing == null ? const StoreMonitorRequest() : const UpdateMonitorRequest();
@override
Future persist(Map validated) async {
final monitor = editing ?? Monitor();
monitor.fill(validated, strict: true);
return monitor.save();
}
}
See doc/basics/forms.md.
Repositories Own Rows
A Repository is an id-keyed cache of one remote resource's rows (Eloquent's identity map); a RepositoryQuery is one ordered, filtered, paginated view over it, so a list screen and a show screen reading the same row see the same cached copy without a refetch. Neither owns navigation or form state.
class MonitorRepository extends Repository {
static MonitorRepository instance = MonitorRepository();
@override
String get resource => 'monitors';
@override
Monitor Function(Map) get fromMap => Monitor.fromMap;
@override
Set get showOnlyKeys => const {'uptime_24h'};
}
See doc/eloquent/repositories.md.
SessionScope Resets Every Tenant Holder
SessionScope is the seam that keeps a login or a team switch from ever showing one tenant's cached rows to another: it resets every SessionScoped controller (found via Magic.controllers) and every registered SessionScoped holder (a Repository registers itself in its constructor) on an identity change, in place, never by deleting and recreating the instance.
@override
Future boot() async {
SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null;
SessionScope.attach(); // explicit, and LAST: realtime and polling must already point at the new session
}
See doc/digging-deeper/session-scope.md.
Listeners Replace a Central Realtime Dispatcher
BroadcastListeners owns exactly one AuthChannelSubscription per alias, no matter how many controllers listen on it, fanning out to every registered handler instead of one controller's subscription silently dropping another's. A controller declares what it wants via ListensToBroadcasts.listeners and never talks to Echo directly.
class MonitorController extends MagicController with ListensToBroadcasts {
@override
Map get listeners => {
'team:check.recorded': (event) => MonitorRepository.instance.patch(
event.data['monitor_id'] as String,
{'last_status': event.data['status']},
),
};
}
See doc/digging-deeper/broadcasting.md#auth-scoped-subscriptions.
How the Six Compose
A monitor list screen, put together: MonitorController (reads) holds a RepositoryQuery over MonitorRepository.instance (rows), runs PauseMonitor (a write) through RunsActions, edits through MonitorFormObject (form state), stays correct across a team switch because the repository is SessionScoped, and stays live because MonitorController mixes in ListensToBroadcasts instead of opening its own channel. No primitive here is optional scaffolding: each closes a failure mode the others do not (a write racing itself, a tenant's rows outliving its session, two controllers fighting over one socket) that a single fat controller used to absorb by accident.