/* voltgrid */ → code
VoltGrid Code
Nachhaltiger Code ist geschichtet: HTTP bleibt dünn, Geschäftslogik lebt in wiederverwendbaren Services, und jede Schicht ist einzeln testbar. Hier wandert ein Request durch die Backend-Schichten, und im Frontend entsteht aus Util, Composable und UI-Primitive ein Button, ohne dass irgendetwas doppelt existiert.
// backend, ein request wandert durch die schichten
Der Controller übersetzt nur HTTP: Request rein, Resource raus. Keine Logik, nichts zu mocken.
final class SessionController
{
public function store(StartSessionRequest $request, StartChargingSession $action): JsonResponse
{
return SessionResource::make($action->handle($request->toCommand()))
->response()
->setStatusCode(201);
}
}Die Action ist der Use-Case: Guards mit eigenen Exception-Klassen, dann Delegation an die Domäne. Isoliert testbar, genau das prüft die Testsuite-Seite.
final class StartChargingSession
{
public function __construct(
private readonly StationRepository $stations,
private readonly TariffCalculator $tariff,
private readonly EventDispatcher $events,
) {
}
public function handle(StartSessionCommand $command): ChargingSession
{
$station = $this->stations->findOrFail($command->stationId);
if (! $station->supports($command->connectorType)) {
throw new UnsupportedConnectorException($station, $command->connectorType);
}
if (! $station->isAvailable()) {
throw new StationUnavailableException($station);
}
$estimate = $this->tariff->estimate($station, $command->estimatedKwh);
$session = ChargingSession::start($station, $command->connectorType, $estimate);
$this->events->dispatch(new SessionStarted($session));
return $session;
}
}Der Service ist die eine Wahrheit für Preise: Value Objects rein (KilowattHours), Money raus. Kein Framework-Import, pure, wiederverwendbare Domänen-Logik.
final class TariffCalculator
{
public function __construct(private readonly TariffRepository $tariffs)
{
}
public function estimate(Station $station, KilowattHours $kwh): Money
{
$tariff = $this->tariffs->activeFor($station);
return $tariff->pricePerKwh()
->multiply($kwh->value())
->add($tariff->baseFee());
}
}↺ wiederverwendet von: StartChargingSession · FinalizeSessionInvoice · GET /stations/{id}/price-preview
Der Beweis der Wiederverwendung: Der Billing-Worker in der Queue nutzt exakt denselben Service, Live-Anzeige und Rechnung können nie auseinanderlaufen.
final class FinalizeSessionInvoice implements ShouldQueue
{
public function __construct(private readonly TariffCalculator $tariff)
{
}
public function handle(SessionCompleted $event): void
{
// derselbe TariffCalculator wie im Live-Estimate:
// ein Preis-Codepfad · Anzeige und Rechnung können nie abweichen
$amount = $this->tariff->estimate($event->station, $event->chargedKwh);
Invoice::issueFor($event->session, $amount);
}
}// frontend, util → composable → komponente
Ein Util für alle Formulare: 422-Antworten der API werden einmal zentral in Feldfehler übersetzt.
// utils/http.ts
export function extractFieldErrors(error: unknown): Record<string, string> {
if (isFetchError(error) && error.statusCode === 422) {
return error.data?.data?.errors ?? {}
}
return { _global: 'unexpected error, please retry' }
}↺ wiederverwendet von: useChargingSession · useStationFilter · useOperatorLogin
Das Composable kapselt den Zustand als Discriminated Union, unmögliche Zustände sind nicht darstellbar, und jede Komponente kann es nutzen.
// composables/useChargingSession.ts
type SessionState =
| { status: 'idle' }
| { status: 'starting' }
| { status: 'charging', session: SessionReceipt }
| { status: 'failed', errors: Record<string, string> }
export function useChargingSession() {
const state = ref<SessionState>({ status: 'idle' })
async function start(payload: StartSessionPayload): Promise<void> {
state.value = { status: 'starting' }
try {
const { data } = await $fetch<{ data: SessionReceipt }>('/api/v1/sessions', {
method: 'POST',
body: payload,
})
state.value = { status: 'charging', session: data }
}
catch (error) {
state.value = { status: 'failed', errors: extractFieldErrors(error) }
}
}
const isCharging = computed(() => state.value.status === 'charging')
return { state: readonly(state), start, isCharging }
}Die Komponente komponiert nur noch: Logik aus dem Composable, Optik aus der DgButton-Primitive. Sieben Zeilen, nichts doppelt.
<script setup lang="ts">
const props = defineProps<{ stationId: number }>()
const { state, start, isCharging } = useChargingSession()
</script>
<template>
<DgButton
:disabled="isCharging"
@click="start({ stationId: props.stationId, connectorType: 'CCS', estimatedKwh: 40 })"
>
{{ isCharging ? '⚡ lädt …' : '→ laden' }}
</DgButton>
</template>↺ DgButton ist dieselbe Primitive wie überall auf dieser Seite, ein Stil, ein Ort für Änderungen
// Konventionen aus echten Projekten: Methoden-Parameter immer mit Type-Hints, Fehler als eigene Exception-Klassen statt generischem try/catch, Kommentare nur wo Business-Logik erklärungsbedürftig ist.