Stringi w C# – Generowanie Dokumentów i Raportów dla Użytkownika

Kąt widzenia: user-facing/biznesowy — faktury, raporty, maile, eksporty
Przykład przewodni: system fakturowania z obsługą wielu kultur
Czym różni się string dla użytkownika od stringa w backendzie?
Gdy budujesz loga lub wyjątek, odbiorcą jest programista — liczy się precyzja i kontekst techniczny. Gdy budujesz fakturę, raport lub maila, odbiorcą jest użytkownik lub klient biznesowy — i tu reguły są zupełnie inne:
- Liczba
1249.99powinna wyglądać jako1 249,99 zł(polski klient) albo$1,249.99(klient z USA). - Data
2026-06-15powinna wyglądać jako15.06.2026alboJune 15, 2026zależnie od kultury. - Kolumny w raporcie muszą być wyrównane, żeby były czytelne.
- Długi dokument generujesz raz i zapisujesz — nie chcesz tworzyć tysięcy pośrednich stringów.
Ten post pokrywa dokładnie te scenariusze.
Scenariusz: system fakturowania
Budujemy InvoiceGenerator – serwis generujący faktury VAT w formacie tekstowym. Faktura trafia:
- do pliku
.txt(archiwum), - jako treść maila (plain text),
- ewentualnie do PDF przez zewnętrzną bibliotekę (ale my generujemy tekst źródłowy).
public class Invoice
{
public string Number { get; init; } = string.Empty;
public DateOnly IssueDate { get; init; }
public DateOnly DueDate { get; init; }
public Company Seller { get; init; } = default!;
public Company Buyer { get; init; } = default!;
public List<InvoiceItem> Items { get; init; } = [];
public CultureInfo Culture { get; init; } = CultureInfo.GetCultureInfo("pl-PL");
}
public record Company(string Name, string TaxId, string Address);
public record InvoiceItem(string Description, int Quantity, decimal UnitNetPrice, decimal VatRate);
CultureInfo – formatowanie liczb i dat dla konkretnego użytkownika
To najważniejszy temat w tej kategorii, którego nie znajdziesz w tutorialach o “podstawach interpolacji”.
Problem z domyślnym formatowaniem
decimal amount = 1249.99m;
DateTime date = DateTime.Now;
// To zależy od ustawień systemu operacyjnego serwera — nie klienta!
Console.WriteLine($"{amount:F2}"); // "1249,99" albo "1249.99" — nie wiesz które
Console.WriteLine($"{date:d}"); // "15.06.2026" albo "6/15/2026" — nie wiesz które
Jeśli Twój serwer jest skonfigurowany jako en-US, polski użytkownik dostanie fakturę z kropką dziesiętną. Albo gorzej — kwoty wyglądają jak 1.249,99 bo serwer jest inaczej skonfigurowany niż środowisko deweloperskie.
Rozwiązanie: jawne podawanie kultury
public class InvoiceGenerator
{
public string Generate(Invoice invoice)
{
var culture = invoice.Culture; // pl-PL, en-US, de-DE, etc.
// Formatowanie z jawną kulturą — zawsze przewidywalny wynik
string formattedAmount = invoice.TotalGross.ToString("N2", culture);
string formattedDate = invoice.IssueDate.ToString("d", culture);
}
}
Kultury w praktyce
var amount = 1249.99m;
Console.WriteLine(amount.ToString("C2", CultureInfo.GetCultureInfo("pl-PL")));
// 1 249,99 zł
Console.WriteLine(amount.ToString("C2", CultureInfo.GetCultureInfo("en-US")));
// $1,249.99
Console.WriteLine(amount.ToString("C2", CultureInfo.GetCultureInfo("de-DE")));
// 1.249,99 €
Console.WriteLine(amount.ToString("N2", CultureInfo.GetCultureInfo("pl-PL")));
// 1 249,99 (bez symbolu waluty — do obliczeń w dokumencie)
C2 — format walutowy z symbolem i dwoma miejscami po przecinku.N2 — format liczbowy bez symbolu waluty.d — krótka data w formacie kultury.
StringBuilder – wydajne budowanie długich dokumentów
Faktura może mieć dziesiątki pozycji. Każde + tworzy nowy obiekt string w pamięci – przy 50 pozycjach to 50+ niepotrzebnych alokacji.
Dlaczego StringBuilder tu ma sens (a w backendzie też)
// Złe dla dużych dokumentów — O(n²) alokacje
string invoice = "";
invoice += "FAKTURA VAT\n";
invoice += $"Numer: {invoiceNumber}\n";
foreach (var item in items)
invoice += $"{item.Description}: {item.Total:N2}\n";
// Dobre — jeden bufor, jeden string na końcu
var sb = new StringBuilder(capacity: 4096); // pre-allocate dla faktury
sb.AppendLine("FAKTURA VAT");
sb.AppendLine($"Numer: {invoiceNumber}");
foreach (var item in items)
sb.AppendLine($"{item.Description}: {item.Total:N2}");
string invoice = sb.ToString(); // jeden string na wyjściu
capacity: 4096 – hint dla alokacji. Jeśli wiesz z grubsza ile znaków wygenerujesz, pomaga uniknąć realokacji bufora.
PadLeft / PadRight – wyrównanie kolumn w dokumencie tekstowym
Faktury i raporty tekstowe wymagają wyrównania. PadLeft/PadRight jest czytelniejsze i bardziej elastyczne niż specyfikatory alignment w interpolacji gdy masz zmienne szerokości kolumn.
private string FormatItemRow(InvoiceItem item, CultureInfo culture)
{
string description = item.Description.PadRight(40);
string qty = item.Quantity.ToString().PadLeft(5);
string unitPrice = item.UnitNetPrice.ToString("N2", culture).PadLeft(12);
string total = (item.Quantity * item.UnitNetPrice).ToString("N2", culture).PadLeft(12);
return $"{description}{qty}{unitPrice}{total}";
}
Wynik:
Usługa projektowa — etap 1 2 800,00 1 600,00
Licencja oprogramowania roczna 1 2 400,00 2 400,00
Szkolenie online (5h) 3 350,00 1 050,00
Alternatywnie, specyfikatory alignment w interpolacji:
return $"{item.Description,-40}{item.Quantity,5}{item.UnitNetPrice,12:N2}{total,12:N2}";
Oba podejścia dają ten sam efekt. PadLeft/PadRight jest bardziej czytelne gdy wyrównanie zależy od obliczonej wartości, specyfikatory ,-N są zwięźlejsze dla stałych szerokości.
Raw string literals (C# 11) – wieloliniowe szablony bez chaosu
Jeśli generujesz szablony maili lub dokumenty HTML w kodzie, raw string literals eliminują problem cudzysłowów i escape characters.
// Szablon maila jako raw string literal
string emailTemplate = """
Szanowni Państwo,
W załączniku przesyłamy fakturę VAT nr {0} z dnia {1}.
Kwota do zapłaty: {2}
Termin płatności: {3}
Prosimy o dokonanie przelewu na numer konta:
PL 61 1090 1014 0000 0712 1981 2874
W razie pytań prosimy o kontakt: faktury@firma.pl
Z poważaniem,
Dział Finansowy
""";
string email = string.Format(emailTemplate,
invoice.Number,
invoice.IssueDate.ToString("d", invoice.Culture),
invoice.TotalGross.ToString("C2", invoice.Culture),
invoice.DueDate.ToString("d", invoice.Culture));
Raw string literal zaczyna się i kończy """. Możesz wewnątrz używać cudzysłowów, backslashy, nawiasów klamrowych – wszystko jest traktowane dosłownie. Wcięcie pierwszego """ zamykającego określa, ile spacji jest usuwane z każdej linii (dedentacja).
Interpolowany raw string literal
Jeśli chcesz łączyć raw strings z interpolacją:
string subject = $$"""
Faktura VAT nr {{invoice.Number}} — kwota {{invoice.TotalGross:C2}}
""";
$$ oznacza: użyj {{ i }} jako separatorów interpolacji. Pozwala na wstawianie { i } w treści bez escapowania.
string.Join – listy i wyliczenia w dokumentach
Częsty case: lista pozycji jako jeden string, np. w treści maila lub raportu CSV.
// Lista opisów pozycji faktury
var descriptions = invoice.Items.Select(i => i.Description);
string itemList = string.Join(", ", descriptions);
// "Usługa projektowa, Licencja oprogramowania, Szkolenie online"
// CSV export pozycji
var csvLines = invoice.Items.Select(i =>
$"{i.Description};{i.Quantity};{i.UnitNetPrice.ToString("F2", culture)}");
string csv = string.Join(Environment.NewLine, csvLines);
string.Join jest wydajniejszy niż ręczna pętla z += — używa StringBuilder wewnętrznie.
Verbatim strings – ścieżki do plików archiwum
public class InvoiceArchiveService
{
private readonly string _archiveRoot;
public InvoiceArchiveService(IConfiguration config)
{
// Ścieżka z konfiguracji — już jako poprawny string
_archiveRoot = config["Invoice:ArchivePath"]
?? @"C:\Faktury\Archiwum"; // fallback jako verbatim string
}
public string GetInvoicePath(Invoice invoice)
{
// Rok/miesiąc jako podfoldery
string yearMonth = invoice.IssueDate.ToString("yyyy-MM");
// Path.Combine — nie sklejaj ścieżek ręcznie!
return Path.Combine(
_archiveRoot,
yearMonth,
$"{invoice.Number.Replace("/", "-")}.txt");
}
}
@"C:\Faktury\Archiwum" – verbatim string w wartości domyślnej: brak podwójnych backslashy, czytelna ścieżka Windows. W produkcji jednak wartość powinna zawsze pochodzić z konfiguracji, nie z hardcoded stringa.
Kompletny generator faktury
Teraz łączymy wszystko razem:
public class InvoiceGenerator
{
public string Generate(Invoice invoice)
{
var culture = invoice.Culture;
var sb = new StringBuilder(capacity: 4096);
// --- Nagłówek ---
string separator = new string('=', 70);
sb.AppendLine(separator);
sb.AppendLine("FAKTURA VAT".PadLeft(45));
sb.AppendLine(separator);
// --- Dane dokumentu ---
sb.AppendLine($"Numer: {invoice.Number}");
sb.AppendLine($"Data wystawienia: {invoice.IssueDate.ToString("d", culture)}");
sb.AppendLine($"Termin płatności: {invoice.DueDate.ToString("d", culture)}");
sb.AppendLine();
// --- Sprzedawca / Nabywca ---
string sellerBlock = FormatCompanyBlock("SPRZEDAWCA", invoice.Seller);
string buyerBlock = FormatCompanyBlock("NABYWCA", invoice.Buyer);
// Kolumnowy układ: sprzedawca po lewej, nabywca po prawej
var sellerLines = sellerBlock.Split('\n');
var buyerLines = buyerBlock.Split('\n');
int rowCount = Math.Max(sellerLines.Length, buyerLines.Length);
for (int i = 0; i < rowCount; i++)
{
string left = i < sellerLines.Length ? sellerLines[i] : "";
string right = i < buyerLines.Length ? buyerLines[i] : "";
sb.AppendLine($"{left.PadRight(35)}{right}");
}
sb.AppendLine();
// --- Tabela pozycji ---
string itemHeader = $"{"Opis",-40}{"Ilość",5}{"Cena netto",12}{"VAT%",6}{"Wartość brutto",15}";
sb.AppendLine(new string('-', 70));
sb.AppendLine(itemHeader);
sb.AppendLine(new string('-', 70));
decimal totalNet = 0;
decimal totalGross = 0;
foreach (var item in invoice.Items)
{
decimal netTotal = item.Quantity * item.UnitNetPrice;
decimal vatAmount = netTotal * item.VatRate;
decimal grossTotal = netTotal + vatAmount;
totalNet += netTotal;
totalGross += grossTotal;
sb.AppendLine(
$"{item.Description,-40}" +
$"{item.Quantity,5}" +
$"{item.UnitNetPrice.ToString("N2", culture),12}" +
$"{item.VatRate * 100,5:F0}%" +
$"{grossTotal.ToString("N2", culture),15}");
}
sb.AppendLine(new string('-', 70));
// --- Podsumowanie ---
sb.AppendLine($"{"RAZEM NETTO:",-55}{totalNet.ToString("N2", culture),15}");
sb.AppendLine($"{"RAZEM BRUTTO:",-55}{totalGross.ToString("N2", culture),15}");
sb.AppendLine($"{"DO ZAPŁATY:",-55}{totalGross.ToString("C2", culture),15}");
sb.AppendLine(separator);
return sb.ToString();
}
private static string FormatCompanyBlock(string label, Company company)
{
return $"{label}\n{company.Name}\nNIP: {company.TaxId}\n{company.Address}";
}
}
Przykładowy wynik dla kultury pl-PL:
======================================================================
FAKTURA VAT
======================================================================
Numer: FV/2026/06/0047
Data wystawienia: 15.06.2026
Termin płatności: 29.06.2026
SPRZEDAWCA NABYWCA
Acme Software sp. z o.o. XYZ Corp Sp. k.
NIP: 123-456-78-90 NIP: 987-654-32-10
ul. Programistów 1, 00-001 Warszawa ul. Kliencka 5, 30-001 Kraków
----------------------------------------------------------------------
Opis Ilość Cena netto VAT% Wartość brutto
----------------------------------------------------------------------
Usługa projektowa — etap 1 2 800,00 23% 1 968,00
Licencja oprogramowania roczna 1 2 400,00 23% 2 952,00
Szkolenie online (5h) 3 350,00 23% 1 291,50
----------------------------------------------------------------------
RAZEM NETTO: 3 550,00
RAZEM BRUTTO: 6 211,50
DO ZAPŁATY: 6 211,50 zł
======================================================================
Zmiana kultury – ta sama faktura po angielsku
var invoice = new Invoice
{
// ... te same dane ...
Culture = CultureInfo.GetCultureInfo("en-US")
};
string invoiceEn = generator.Generate(invoice);
Wynik — bez żadnych zmian w logice generatora:
DO ZAPŁATY: $6,211.50
Data wystawienia: 6/15/2026
Cała logika formatowania działa przez kulturę — nie ma if (language == "pl") w kodzie.
Edge case: string.IsNullOrWhiteSpace w danych wejściowych
Zanim wygenerujesz dokument, waliduj dane. Puste pole Company.Name w nagłówku faktury to błąd nie do zaakceptowania.
public string Generate(Invoice invoice)
{
ArgumentException.ThrowIfNullOrWhiteSpace(invoice.Number, nameof(invoice.Number));
if (invoice.Items.Count == 0)
throw new InvalidOperationException(
$"Faktura {invoice.Number} nie zawiera żadnych pozycji.");
if (invoice.DueDate < invoice.IssueDate)
throw new ArgumentException(
$"Termin płatności ({invoice.DueDate:d}) nie może być wcześniejszy " +
$"niż data wystawienia ({invoice.IssueDate:d}).",
nameof(invoice.DueDate));
// ... generowanie
}
ArgumentException.ThrowIfNullOrWhiteSpace — dostępny od .NET 7, zastępuje ręczne sprawdzenie + throw.
Checklist — stringi w dokumentach dla użytkownika
- Zawsze podawaj CultureInfo – nigdy nie polegaj na kulturze systemowej serwera.
- Używaj
C2dla walut – z kulturą;N2gdy chcesz liczbę bez symbolu waluty. StringBuilderdla dokumentów – jeśli generujesz więcej niż kilka linii w pętli.string.Joinzamiast ręcznej pętli z+=dla list.- Raw string literals (
""") dla wieloliniowych szablonów maili, HTML, SQL. PadLeft/PadRightlub specyfikatory alignment dla wyrównanych kolumn.Path.Combinezamiast ręcznego sklejania ścieżek – zawsze.- Waliduj dane wejściowe przed generowaniem – pusty NIP na fakturze to problem.
Podsumowanie
Generowanie dokumentów dla użytkownika wymaga precyzji innego rodzaju niż backend – nie chodzi o structured logging ani refactor-safe nazwy, ale o to, żeby liczba wyglądała poprawnie niezależnie od tego, gdzie stoi serwer. CultureInfo to punkt wyjścia. StringBuilder to wydajność. Raw string literals to czytelność szablonów. Reszta to wyrównanie i detal, który decyduje czy wydrukowana faktura wygląda profesjonalnie.
Poprzedni post: C# Stringi – Interpolacja i Formatowanie w Logach i Komunikatach Błędów — ten sam temat, zupełnie inna perspektywa: ILogger, message templates, wyjątki z kontekstem i odpowiedzi API.
Zobacz także — powiązane artykuły
👉 MCP w .NET (C#) – jak zbudować serwer AI krok po kroku
👉 Tworzenie klas i obiektów w C# — kompletny przewodnik
👉 Pattern Matching w C# – switch expressions i type patterns
CTA
📌 Jakiego podejścia do stringów używasz najczęściej?
Zostaw komentarz pod artykułem.
📌 Chcesz więcej praktycznego C# i .NET?
👉 sprawdź moje kursy .NET i C#.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →
1 comment