/* 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

http

Der Controller übersetzt nur HTTP: Request rein, Resource raus. Keine Logik, nichts zu mocken.

Http/Controllers/SessionController.phpphp
final class SessionController
{
    public function store(StartSessionRequest $request, StartChargingSession $action): JsonResponse
    {
        return SessionResource::make($action->handle($request->toCommand()))
            ->response()
            ->setStatusCode(201);
    }
}
application

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.

Charging/Actions/StartChargingSession.phpphp
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;
    }
}
domain · service

Der Service ist die eine Wahrheit für Preise: Value Objects rein (KilowattHours), Money raus. Kein Framework-Import, pure, wiederverwendbare Domänen-Logik.

Domain/Tariff/TariffCalculator.phpphp
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

queue · worker

Der Beweis der Wiederverwendung: Der Billing-Worker in der Queue nutzt exakt denselben Service, Live-Anzeige und Rechnung können nie auseinanderlaufen.

Billing/Jobs/FinalizeSessionInvoice.phpphp
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

util

Ein Util für alle Formulare: 422-Antworten der API werden einmal zentral in Feldfehler übersetzt.

utils/http.tsts
// 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

composable · logic

Das Composable kapselt den Zustand als Discriminated Union, unmögliche Zustände sind nicht darstellbar, und jede Komponente kann es nutzen.

composables/useChargingSession.tsts
// 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 }
}
component · ui

Die Komponente komponiert nur noch: Logik aus dem Composable, Optik aus der DgButton-Primitive. Sieben Zeilen, nichts doppelt.

components/ChargeButton.vuevue
<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.