Kleines Deployment, großes Problem: Wie eine einfache Formularfunktion in Produktion scheitert

Eine kleine Funktion nach dem Formular prüft den gesamten Produktionsprozess: Frontend-Zustand, API-Idempotenz, Stripe, E-Mail, Secrets, Smoke-Tests, Azure und Rollback. Ein praktischer Weg zum Deployment ohne Rätselraten.

Der vollständige Artikel steht unten. Aktivieren Sie JavaScript für Quiz und Simulationen.

Production-ready in der Praxis

Eine kleine Funktion kann den gesamten Deployment-Prozess prüfen.

Ein Dialog nach dem Formular, drei Beträge und eine Weiterleitung zur Zahlung. Auf dem Bildschirm einfach, in Produktion berührt das Frontend-Zustand, API, Idempotenz, Stripe, E-Mails, Secrets, Smoke-Tests, Analytics und Rollback.

Manche Funktionen wirken im Entwurf banal: ein Dialog, drei Buttons, eine kurze Erklärung, dann die Zahlungsweiterleitung. Fertig.

Produktion beurteilt eine Funktion aber nicht danach, wie sie in Figma oder lokal unter localhostaussieht. Entscheidend ist, ob Nutzer handeln können, das Backend nicht zu viel verrät, Checkout-Beschreibung und E-Mail stimmen, die neue Binärdatei läuft, Prüfungen nachvollziehbar sind und ein Rückweg besteht.

Dieser Beitrag behandelt genau diesen Fall: Eine einfache Funktion nach dem Formular kann zwischen Frontend, API, Konfiguration, Stripe, E-Mail und Deployment auseinanderlaufen. Sie wirkt klein, ist aber ein Test der Produktionsreife.

Worum es tatsächlich ging

Stell dir vor: Nach einer erfolgreich abgesendeten Anfrage erscheint optional „Wenn du möchtest, kannst du einen Kaffee für schnelleres Feedback spendieren“. Das ist kein Dienstleistungskauf, sondern eine freiwillige Ergänzung, die klar, angenehm und ohne Druck sein soll.

Auf dem Papier ist der Ablauf einfach:

  • Das Formular sendet die Nachricht,
  • das Backend speichert den Kontakt,
  • das Frontend zeigt den Dialog,
  • der Nutzer schließt ihn oder geht zu Stripe,
  • Stripe erstellt den Checkout,
  • das System speichert eine Spur,
  • anschließend geht es zurück zur Website.

Klingt einfach. Gerade deshalb wird es leicht falsch umgesetzt.

Frontend

Der Dialogzustand braucht einen Verantwortlichen. Sonst verändert „Schließen“ möglicherweise nichts.

Backend

Checkout braucht Validierung und Idempotenz. Ein Klick darf kein Chaos erzeugen.

Deployment

Ein neues Frontend ohne neue API ist nur ein halbes Deployment. Produktion braucht Nachweise.

Häufige Fehler bei dieser Funktion:

  • Der Dialog schließt nicht, weil sein Zustand fest verdrahtet ist,
  • der Zahlungsbutton lädt endlos, weil Timeout oder API-Fehler unbehandelt bleiben,
  • das Backend erzeugt ohne Idempotenz mehrere Checkout-Sitzungen für dieselbe Aktion,
  • Stripe zeigt eine alte Beschreibung, weil nur das Frontend ausgerollt wurde,
  • die Bestätigungs-E-Mail ist schlecht gestaltet oder das Logo lädt in Gmail nicht,
  • Analytics erfasst das wichtige Ereignis nicht,
  • der Smoke-Test prüft nur /healthstatt des Nutzerablaufs,
  • nach dem Deployment ist die tatsächlich laufende Version unklar.

Hier hört die „kleine Funktion“ auf, klein zu sein.

Erste Falle: Die UI funktioniert lokal, ihr Zustand ist aber nicht produktionstauglich

Der einfachste Fehler: ein Dialog, der sich nicht schließen lässt. Im Code wirkt das harmlos:

<app-coffee-offer-modal [open]="true"></app-coffee-offer-modal>

Ein kleines Detail mit schweren Folgen. Ist open fest gesetzt, kann die Komponente closeemittieren, ihren Zustand löschen und intern alles richtig machen – der Parent verlangt trotzdem: „Der Dialog bleibt offen.“

Die Lösung folgt einer einfachen Regel: Der Zustand gehört dem Prozess, nicht dem Zufall.

isCoffeeOpen = false;
lastContactId: string | null = null;

onContactSent(contactId: string): void {
  this.lastContactId = contactId;
  this.isCoffeeOpen = true;
}

closeCoffeeOffer(): void {
  this.isCoffeeOpen = false;
}
<app-coffee-offer-modal
  *ngIf="lastContactId"
  [open]="isCoffeeOpen"
  [sourceId]="lastContactId"
  sourceType="contact"
  (close)="closeCoffeeOffer()">
</app-coffee-offer-modal>

Das Beispiel ist schlicht, zeigt aber das Prinzip: Ein produktives Frontend darf nicht auf „wird schon irgendwie angezeigt“ beruhen. Zustand braucht Verantwortung, Einstieg und Ausstieg.

Zweite Falle: Checkout darf keine beliebige Weiterleitung sein

Zahlungsweiterleitung scheint einfach: Button klicken, URL vom Backend erhalten, im Frontend window.location.assign(url)aufrufen.

Doch bei leerer, falscher oder nicht zu Stripe gehörender URL und mehrfachen Klicks entsteht Chaos.

private redirectToCheckout(rawUrl: string): boolean {
  try {
    const url = new URL(rawUrl, window.location.origin);
    const isStripeCheckout =
      url.protocol === 'https:' &&
      (url.hostname === 'checkout.stripe.com' || url.hostname.endsWith('.stripe.com'));

    if (!isStripeCheckout) {
      return false;
    }

    window.location.assign(url.toString());
    return true;
  } catch {
    return false;
  }
}

Das ist noch keine vollständige Zahlungssicherheit, aber wichtige Hygiene. Das Frontend darf nicht blind jeder URL aus der API folgen.

Backend: Validierung, Idempotenz und klare Beschreibung

Das Backend braucht Validierung, Idempotenz und ein ausdrückliches Modell erlaubter Aktionen. Bei Zahlungen gilt das auch dann, wenn sie freiwillig sind.

public sealed record CoffeeCheckoutCreateDto(
    string SourceType,
    Guid SourceId,
    int AmountPln,
    string? Email);

private static readonly int[] AllowedAmounts = [15, 25, 50];

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    AppDbContext db,
    IStripeCheckoutFactory stripe,
    CancellationToken ct) =>
{
    if (!AllowedAmounts.Contains(dto.AmountPln))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["amountPln"] = ["Unsupported amount."]
        });
    }

    var sourceType = dto.SourceType.Trim().ToLowerInvariant();
    if (sourceType is not ("contact" or "audit"))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["sourceType"] = ["Unsupported source type."]
        });
    }

    var idempotencyKey = $"{sourceType}:{dto.SourceId}:{dto.AmountPln}";

    var existing = await db.CoffeeCheckouts
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.IdempotencyKey == idempotencyKey, ct);

    if (existing?.StripeSessionUrl is not null)
    {
        return Results.Ok(new { url = existing.StripeSessionUrl });
    }

    var checkout = await stripe.CreateSessionAsync(new StripeCoffeeSessionRequest
    {
        AmountPln = dto.AmountPln,
        Email = dto.Email,
        ProductName = "Ein Kaffee für Feedback und eine schnellere Rückmeldung",
        ProductDescription =
            "Freiwilliger Kaffeebeitrag für die Ausarbeitung von Feedback, die Analyse der Anfrage und konkrete nächste Schritte. Er ersetzt keine Projektkalkulation.",
        IdempotencyKey = idempotencyKey
    }, ct);

    db.CoffeeCheckouts.Add(new CoffeeCheckout
    {
        Id = Guid.NewGuid(),
        SourceType = sourceType,
        SourceId = dto.SourceId,
        AmountPln = dto.AmountPln,
        IdempotencyKey = idempotencyKey,
        StripeSessionId = checkout.SessionId,
        StripeSessionUrl = checkout.Url,
        Status = "created",
        CreatedAt = DateTimeOffset.UtcNow
    });

    await db.SaveChangesAsync(ct);

    return Results.Ok(new { url = checkout.Url });
});

Drei Dinge sind besonders wichtig:

  • Beträge stammen aus einer Allowlist statt beliebigen Eingaben,
  • die Quelle ist ausdrücklich und begrenzt,
  • dieselbe Aktion erzeugt nicht unbegrenzt neue Sitzungen.

Wenig Code kann hier in Produktion viel bewirken.

Azure: Konfiguration außerhalb des Repositories und Feature Flags

Lokal ist es leicht, einen Schlüssel in appsettings.jsoneinzutragen, das Projekt zu starten und „läuft“ zu sagen. In Produktion ist das der falsche Weg.

Stripe-Schlüssel, Webhook-Secrets, Passwörter, Connection Strings und Deployment-Tokens gehören nicht ins Repository. Sie gehören in eine kontrollierte Umgebung, einen Vault oder einen geeigneten Konfigurationsdienst.

Ein sinnvoller Azure-Baukasten ist:

  • Azure App Configuration für Schalter und Betriebseinstellungen,
  • Azure Key Vault für Secrets,
  • Managed Identity statt dauerhafter Passwörter,
  • Deployment Slots für kontrolliertes Ausrollen.
using Azure.Identity;
using Microsoft.FeatureManagement;

var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential();

var appConfigEndpoint = builder.Configuration["Azure:AppConfigurationEndpoint"];

if (!string.IsNullOrWhiteSpace(appConfigEndpoint))
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(appConfigEndpoint), credential)
            .ConfigureKeyVault(kv => kv.SetCredential(credential))
            .UseFeatureFlags(flagOptions =>
            {
                flagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
            });
    });

    builder.Services.AddAzureAppConfiguration();
    builder.Services.AddFeatureManagement();
}

Dazu ein Feature Flag:

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    IFeatureManager featureManager,
    CancellationToken ct) =>
{
    if (!await featureManager.IsEnabledAsync("CoffeeCheckout", ct))
    {
        return Results.Problem(
            title: "Feature disabled",
            detail: "Coffee checkout is currently unavailable.",
            statusCode: StatusCodes.Status503ServiceUnavailable);
    }

    // eigentliche Checkout-Logik
    return Results.Ok();
});

So muss bei Problemen nicht sofort die ganze Anwendung zurückgerollt werden. Die einzelne Funktion kann abgeschaltet werden.

Vierte Falle: Frontend-Deployment ist nicht das Deployment der ganzen Funktion

Hier treffen häufig zwei Welten zusammen:

  • Das Frontend zeigt den Dialog und ruft den Endpoint auf,
  • das Backend erzeugt Checkout, verschickt E-Mail und speichert Zustand.

Rollst du nur das Frontend aus, sehen Nutzer die neue Ansicht, Stripe bekommt aber vielleicht die alte Produktbeschreibung. Rollst du nur das Backend aus, existiert neue Logik, die niemand aufruft.

Schicht Was prüfen? Nachweis
Frontend Ist der neue Dialog in den Live-Assets? Build-Hash, main.*.js, Browsertest
API Läuft die neue Binärdatei? Dienstneustart, Status, Startlog
Konfiguration Kommen Secrets aus Env/Vault? Keine Secrets im Repository, Umgebungsvariablen
Stripe Hat die neue Sitzung die richtige Beschreibung? Test-Checkout-Sitzung ohne Zahlung
E-Mail Werden Logo und Inhalt korrekt angezeigt? Test-E-Mail in Gmail
Rollback Lässt sich die Funktion abschalten? Feature Flag oder vorheriger Release

Ein öffentlicher Health-Endpoint darf langweilig sein

Ein Health-Endpoint ist nötig; öffentlich sollte er aber keinen Betriebsbericht für das Internet liefern.

{
  "status": "OK",
  "database": "connected",
  "stripeMode": "live",
  "mailProvider": "smtp",
  "tenantCrypto": "configured",
  "pilotOwnerEmail": "..."
}

Nicht alle Details sind Secrets, sie liefern jedoch fertige Recherchehinweise. Ein öffentlicher Health-Endpoint sollte langweilig bleiben.

app.MapGet("/health", () => Results.Ok(new { status = "OK" }));

Diagnosedetails gehören nur in autorisierte Administration oder Monitoring.

Ein Smoke-Test, der wirklich etwas prüft

Ein Smoke-Test beweist nicht das ganze System. Er erkennt typische Releasefehler. Hier sind Website, Health, Checkout-Validierung, E-Mail-Asset und fehlende Konsolenfehler sinnvoll.

public sealed class ProductionSmokeProbe
{
    private readonly HttpClient _http;

    public ProductionSmokeProbe(HttpClient http)
    {
        _http = http;
    }

    public async Task<SmokeProbeResult> RunAsync(Uri origin, CancellationToken ct)
    {
        var health = await _http.GetFromJsonAsync<HealthDto>(
            new Uri(origin, "/health"), ct);

        if (health?.Status != "OK")
        {
            return SmokeProbeResult.Fail("Health endpoint is not OK.");
        }

        using var invalidCheckout = await _http.PostAsJsonAsync(
            new Uri(origin, "/api/v1/coffee/checkout"),
            new { sourceType = "contact", sourceId = Guid.Empty, amountPln = 999 },
            ct);

        if (invalidCheckout.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Checkout accepted invalid amount.");
        }

        using var logo = await _http.GetAsync(new Uri(origin, "/email/logo.png"), ct);
        if (!logo.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Email logo asset is missing.");
        }

        return SmokeProbeResult.Ok();
    }
}

public sealed record HealthDto(string Status);

public sealed record SmokeProbeResult(bool Success, string Message)
{
    public static SmokeProbeResult Ok() => new(true, "Production smoke passed.");
    public static SmokeProbeResult Fail(string message) => new(false, message);
}

Azure Function als kontrollierte Smoke-Probe

Dieselbe Probe lässt sich als Azure Function manuell nach dem Deployment oder automatisch aus der Pipeline starten.

public sealed class ProductionSmokeFunction
{
    private readonly ProductionSmokeProbe _probe;

    public ProductionSmokeFunction(ProductionSmokeProbe probe)
    {
        _probe = probe;
    }

    [Function("production-smoke")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
        FunctionContext context)
    {
        var result = await _probe.RunAsync(
            new Uri("https://www.arccloudstudio.com"),
            context.CancellationToken);

        var response = req.CreateResponse(result.Success
            ? HttpStatusCode.OK
            : HttpStatusCode.InternalServerError);

        await response.WriteAsJsonAsync(result, context.CancellationToken);
        return response;
    }
}

Wichtig: Sie darf keine echte Zahlung auslösen. Sie prüft Verträge und zentrale Schutzmechanismen des Ablaufs.

Rollback einer kleinen Funktion

Bei kleinen Funktionen wird Rollback oft vergessen. Das ist ein Fehler.

Rollback muss nicht das gesamte Deployment zurücknehmen. Manchmal reicht:

  • Feature Flag deaktivieren,
  • Dialog im Frontend verbergen,
  • Checkout-Erstellung blockieren,
  • zur vorigen Produktbeschreibung zurückkehren,
  • den Prozess auf manuelle Bearbeitung umstellen.
public enum ReleaseDecision
{
    Continue,
    DisableFeature,
    RollbackApi,
    RollbackFrontend,
    ManualHandling
}

public static ReleaseDecision Decide(SmokeProbeResult smoke, int errorRate, bool paymentImpact)
{
    if (!smoke.Success && paymentImpact)
    {
        return ReleaseDecision.DisableFeature;
    }

    if (errorRate > 5)
    {
        return ReleaseDecision.RollbackApi;
    }

    return ReleaseDecision.Continue;
}

Azure App Service: Deployment Slot als Sicherheitspuffer

Für Azure App Service ist ein Deployment Slot ein gutes Muster. Statt direkt in Produktion veröffentlichst du die API im Slot staging, führst Smoke-Tests aus und wechselst erst danach per Swap.

az webapp deployment slot create \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging

az webapp deploy \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --src-path ./publish.zip \
  --type zip

curl -fsS https://arccloud-api-staging.azurewebsites.net/health

az webapp deployment slot swap \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --target-slot production

Sein Wert liegt darin, dass ein Release kein einmaliger Sprung ins Dunkle mehr ist.

Was das für Kunden bedeutet

Kunden sehen weder Idempotenz noch Key Vault oder Feature Flags. Ihnen ist egal, ob das Backend in Slots oder per systemd auf einem VPS läuft.

Sie sehen etwas Einfacheres:

  • Ich klickte, und es funktionierte.
  • Ich schloss den Dialog, und er verschwand.
  • Ich zahlte oder übersprang und wurde nicht blockiert.
  • Ich bekam eine verständliche Meldung.
  • Die Website wirkte nicht wie ein Testexperiment.

Die Technik soll genau diesen einfachen Effekt vorhersehbar machen.

Checkliste vor dem Deployment

Lautet die Antwort mehrmals „weiß ich nicht“, ist die Funktion nicht fertig.

  • Hat der Dialog eine Zustandsverantwortung, und lässt er sich schließen?
  • Validiert die API Eingaben, statt dem Frontend zu vertrauen?
  • Ist Checkout idempotent?
  • Beschreibt Stripe wahrheitsgemäß, wofür bezahlt wird?
  • Verraten öffentliche Endpoints keine Backend-Details?
  • Liegen Secrets außerhalb des Repositories?
  • Sieht die E-Mail in Gmail gut aus?
  • Sind Assets leicht und verfügbar?
  • Erfasst GA4 die relevanten Ereignisse?
  • Ist nachgewiesen, dass Frontend und API die zusammengehörige Version ausführen?
  • Kann die Funktion ohne Gesamtausfall deaktiviert werden?

Fazit

Tückische Produktionsprobleme beginnen oft bei einem kleinen Dialog, einer Weiterleitung, Stripe-Beschreibung, E-Mail, einem Flag oder Endpoint.

Ohne guten Prozess bleibt die Funktion halb fertig: neues Frontend, alte API, unklarer Checkout, nicht schließbarer Dialog und ein Team, das den Produktionsstand nicht kennt.

Ein gutes Deployment ist mehr als build passed. Es beantwortet: Was änderte sich, wo ist der Nachweis, wie messen wir den Effekt, was tun wir bei Fehlern und wie schnell kehren wir zu Stabilität zurück?

Deshalb ist eine einfache Funktion oft der beste Test dafür, ob ein System wirklich production-ready ist.