Skończyłeś 3 kursy .NET. Masz w portfolio aplikację todo, sklep internetowy z tutoriala i może nawet jakieś API. Wysłałeś 80 CV i... cisza. Albo jeszcze gorzej: dostałeś zaproszenie na rozmowę techniczną i poległeś na pierwszym pytaniu, którego nie było w żadnym kursie.
To nie jest Twoja wina. Śmiem twierdzić, że 90% tutoriali .NET uczy rzeczy, które na rozmowie rekrutacyjnej i w pracy po prostu nie wystarczają. Stan, w którym oglądasz kurs za kursem, wszystko rozumiesz, a samodzielnie nie potrafisz napisać niczego większego, ma nawet swoją nazwę: tutorial hell.
W tym artykule pokażę Ci 4 konkretne różnice między tym, czego uczą tutoriale, a tym, czego wymaga praca - na prawdziwym kodzie, nie na teorii. Rozbierzemy na części typowy endpoint z tutoriala (i nadamy sobie w nim uprawnienia admina 1 żądaniem), napiszemy jego produkcyjną wersję, a na koniec dostaniesz plan naprawczy w 5 krokach, czyli co robić zamiast kolejnego kursu.
Wersje .NET: przykłady to ASP.NET Core z kontrolerami i Entity Framework Core, działają w .NET 8, 9 i 10. Do walidacji używam biblioteki FluentValidation, a TimeProvider (abstrakcja czasu, która ułatwia testy) jest dostępny w .NET od wersji 8.
Dlaczego tutoriale uczą źle (i nie jest to wina ich autorów)
Najpierw jedna rzecz, żebyśmy się dobrze zrozumieli: twórcy tutoriali to nie są źli ludzie. Sam tworzę kursy, więc dobrze znam ograniczenia tego formatu. Problem jest systemowy. Każdy tutorial musi spełnić 3 warunki:
• Musi być prosty, więc wycina wszystko, co trudne.
• Musi być krótki, więc wycina wszystko, co czasochłonne.
• Musi działać za pierwszym razem, więc wycina błędy, debugowanie i refaktoring.
Tutorial z definicji pokazuje drogę z punktu A do punktu B bez żadnej przeszkody. A teraz najważniejsze: praca programisty to w jakichś 80% właśnie to, co wycięto. Debugowanie cudzego kodu sprzed 5 lat, niejasne wymagania od klienta, decyzje typu "zrobić szybko czy zrobić dobrze".
Rekruter techniczny wyczuwa to w 10 minut. Widzi, że umiesz odtworzyć tutorial, ale nie umiesz programować. Przejdźmy więc przez 4 konkretne różnice - i przez to, jak je u siebie naprawić.
Błąd #1: uczysz się składni zamiast rozwiązywania problemów
Zestaw obok siebie to, czego uczy tutorial, i to, z czym przychodzisz do pracy:
| Czego uczy tutorial | Czego wymaga praca |
| "Tak się pisze pętlę foreach." | "Dlaczego ten endpoint odpowiada 8 sekund?" |
| "Tak się robi klasę." | "Jak dodać nową funkcję, nie psując 20 innych?" |
| "Przepisz kod za mną." | "Wymagania są niejasne - doprecyzuj je z klientem." |
Widzisz różnicę? To są zupełnie inne umiejętności. Tutorial uczy, jak coś zapisać w C#. Praca wymaga, żebyś najpierw sam ustalił, co w ogóle trzeba zrobić - i właśnie tę różnicę rekruter techniczny wyczuwa w 10 minut rozmowy.
Test na dziś. Zrób sobie prosty test. Otwórz pusty projekt i napisz z głowy API z 1 endpointem, z walidacją i obsługą błędów. Bez podglądania. Nie umiesz? To znaczy, że nie nauczyłeś się programować - nauczyłeś się przepisywać. To tak, jakby uczyć się angielskiego, przepisując książkę. Litery się zgadzają, ale rozmowy nie poprowadzisz.
Jak to naprawić? 3 rzeczy:
1. Po każdym tutorialu odtwórz projekt z pamięci. Może być brzydko - właśnie o to chodzi.
2. Dodaj 1 funkcję, której w tutorialu nie było. Dokładnie w momencie, kiedy dodajesz coś sam i nic nie działa, zaczyna się prawdziwa nauka.
3. Zasada 1:3. Na 1 godzinę kursu - 3 godziny samodzielnego kodowania.
Przykład: tutorial pokazał listę zadań. Ty dodajesz terminy, priorytety i wysyłkę maila przed deadlinem. Każda z tych funkcji zmusi Cię do decyzji, których tutorial nie podjął za Ciebie. Jak zapisać datę i co ze strefami czasowymi? Jak posortować listę jednocześnie po priorytecie i terminie? Kto i kiedy ma wysłać maila, skoro nikt nie klika żadnego przycisku?
Frustracja, którą wtedy poczujesz, to nie znak, że się nie nadajesz. To znak, że właśnie się uczysz.
Błąd #2: kod z tutoriala i 5 problemów, które widzi rekruter
Koniec teorii - patrzymy w kod. Tak wygląda endpoint rejestracji użytkownika w ogromnej części tutoriali:
/* tak uczy większość tutoriali */
[HttpPost]
public IActionResult CreateUser(User user)
{
_context.Users.Add(user);
_context.SaveChanges();
return Ok(user);
}Wygląda znajomo? Kilka linijek, kompiluje się, działa, tutorial kończy się sukcesem. A teraz policzmy problemy. Jest ich co najmniej 5:
1. Encja bazodanowa prosto z internetu. Endpoint przyjmuje encję User, czyli dokładnie ten obiekt, który zapisujemy do bazy. Jeśli encja ma właściwość IsAdmin, użytkownik może po prostu dopisać ją do JSON-a i właśnie został adminem. To się nazywa over-posting (albo mass assignment) i to realna dziura bezpieczeństwa.
2. Zero walidacji. Email może być pusty, hasło może mieć 1 znak.
3. Kod synchroniczny. SaveChanges zamiast SaveChangesAsync. Każde żądanie blokuje wątek na czas zapisu do bazy, a pod większym obciążeniem kończy się to głodzeniem puli wątków (thread pool starvation). Aplikacja odpowiada coraz wolniej, aż w końcu klęka.
4. Zwracamy całą encję. Razem z hasłem. Serio, widziałem to na produkcji.
5. Brak obsługi błędów. Co, jeśli baza rzuci wyjątek, np. przy zduplikowanym emailu? W najlepszym razie użytkownik dostaje gołe 500. W najgorszym - gdy na produkcji zostało włączone środowisko Development albo ktoś w catch zwraca ex.ToString() - stack trace z wnętrznościami aplikacji.
Każdy z tych punktów osobno to temat na code review. Razem dają endpoint, który na produkcji jest tykającą bombą.
Over-posting w praktyce: zostajesz adminem 1 żądaniem
Nie wierzysz, że to realny problem? Wyślijmy do tego endpointa zwykłe żądanie rejestracji. Jedyna różnica: dopisałem do JSON-a pole isAdmin.
POST /api/users
Content-Type: application/json
{
"email": "jan@example.com",
"password": "Haslo123!",
"isAdmin": true
}I co wraca?
HTTP/1.1 200 OK
{
"id": 42,
"email": "jan@example.com",
"password": "Haslo123!",
"isAdmin": true
}200 OK. Użytkownik właśnie sam zrobił się adminem. A w bonusie API oddało mu jego hasło otwartym tekstem, co przy okazji zdradza, że w takiej samej postaci leży ono w bazie.
Dlaczego to działa? Model binding w ASP.NET Core nie wie, które pola są "bezpieczne". Wypełnia każdą właściwość encji, której nazwa pasuje do pola w JSON-ie (wielkość liter nie ma znaczenia), więc isAdmin z żądania trafia prosto do właściwości IsAdmin. 1 żądanie z Postmana i po Twoim systemie uprawnień.
To nie jest teoretyczny problem. Takie rzeczy naprawdę przechodzą przez code review, bo ktoś kiedyś nauczył się tego wzorca z tutoriala i powiela go od lat.
Kod produkcyjny: ten sam endpoint, zero dziur
A teraz wersja, za którą firmy płacą pensje. Zaczynamy od kontraktu, czyli od tego, co wchodzi do API i co z niego wychodzi:
public record CreateUserRequest(string Email, string Password);
public record UserResponse(int Id, string Email);CreateUserRequest to DTO (Data Transfer Object), które ma tylko te pola, które użytkownik może ustawić: email i hasło. Nie ma IsAdmin, więc over-posting znika - nie da się nadpisać pola, którego nie ma. UserResponse zwraca z kolei tylko to, co użytkownik może zobaczyć.
Reguły walidacji trzymamy w osobnej klasie, a nie w kontrolerze:
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email jest wymagany.")
.EmailAddress().WithMessage("Podaj poprawny adres email.");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Hasło jest wymagane.")
.MinimumLength(8).WithMessage("Hasło musi mieć co najmniej 8 znaków.");
}
}Sam endpoint wygląda tak:
[HttpPost]
public async Task<IActionResult> CreateUser(
CreateUserRequest request,
CancellationToken ct)
{
var validationResult = await _validator.ValidateAsync(request, ct);
if (!validationResult.IsValid)
return BadRequest(validationResult.ToDictionary());
var emailTaken = await _context.Users
.AnyAsync(u => u.Email == request.Email, ct);
if (emailTaken)
return Conflict(new { error = "Email jest już zajęty." });
var user = new User
{
Email = request.Email,
PasswordHash = _passwordHasher.Hash(request.Password),
CreatedAt = _timeProvider.GetUtcNow()
};
_context.Users.Add(user);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Utworzono użytkownika {UserId}", user.Id);
return CreatedAtAction(
nameof(GetUser),
new { id = user.Id },
new UserResponse(user.Id, user.Email));
}Przejdźmy przez to, co się zmieniło. Najpierw wejście:
• DTO zamiast encji. Koniec z over-postingiem.
• CancellationToken. ASP.NET Core sam podpina go pod przerwanie żądania. Jak użytkownik zamknie kartę, zapytania do bazy zostaną anulowane i nie marnujemy zasobów serwera.
• Walidacja, zanim cokolwiek dotknie bazy. Fail fast: błędne dane odbijamy na samym początku z kodem 400.
• Sprawdzenie, czy email jest zajęty. Sensowny kod 409 Conflict z czytelnym komunikatem, a nie wybuch z bazy na unikalnym indeksie. Sam indeks na kolumnie Email i tak zostaw - przy 2 równoległych rejestracjach na ten sam adres oba żądania mogą przejść przez AnyAsync. Sprawdzenie daje ładny komunikat w typowym przypadku, a indeks jest ostatnią linią obrony.
Teraz druga połowa, czyli zapis i odpowiedź:
• Encję tworzymy my, nie użytkownik. Każde pole ustawiamy świadomie.
• Hasło jest hashowane - nigdy, przenigdy plain text. _passwordHasher to tu prosta abstrakcja, pod którą powinien siedzieć algorytm przeznaczony do haseł (np. PasswordHasher z ASP.NET Core Identity, BCrypt albo Argon2), a nie zwykły SHA-256.
• Czas z TimeProvider zamiast DateTime.UtcNow, żeby dało się to potem testować. W testach podmienisz go na FakeTimeProvider i ustawisz dowolną datę.
• Zapis asynchroniczny. Na czas zapisu wątek wraca do puli i może obsługiwać inne żądania, zamiast bezczynnie czekać na bazę.
• Log. Za pół roku o 3 w nocy ktoś będzie debugował, czemu konto nie powstało, i podziękuje Ci za tę linijkę. Zwróć uwagę na {UserId}: to logowanie strukturalne, a nie interpolacja stringa, więc w narzędziu do logów (np. Seq czy Application Insights) przefiltrujesz wpisy po konkretnym użytkowniku.
• 201 Created z lokalizacją zasobu. CreatedAtAction dokłada nagłówek Location z adresem nowego użytkownika, a w treści odpowiedzi jest tylko id i email. Tylko to, co użytkownik może zobaczyć.
A co z wyjątkami? Walidacja i sprawdzenie konfliktu eliminują przewidywalne błędy. Na nieprzewidywalne (baza leży, timeout) nie pisz try/catch w każdym endpoincie, tylko ustaw globalną obsługę wyjątków. Cała potrzebna konfiguracja w Program.cs wygląda tak:
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); /* pakiet FluentValidation.DependencyInjectionExtensions */
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); /* nieobsłużony wyjątek = 500 w formacie ProblemDetails, bez stack trace */Nieobsłużony wyjątek zamienia się w odpowiedź 500 w standardowym formacie ProblemDetails, a szczegóły lądują w logach, a nie w przeglądarce użytkownika.
Sprawdzamy: śmieciowe dane i ponowny atak
Test 1: śmieciowe dane. Wysyłamy email, który nie jest emailem, i hasło z 3 znaków:
POST /api/users
Content-Type: application/json
{
"email": "to-nie-jest-email",
"password": "abc"
}Odpowiedź:
HTTP/1.1 400 Bad Request
{
"Email": [ "Podaj poprawny adres email." ],
"Password": [ "Hasło musi mieć co najmniej 8 znaków." ]
}400 z czytelną listą błędów. Baza nietknięta, a frontend dokładnie wie, który komunikat pokazać przy którym polu.
Test 2: ta sama próba ataku. Teraz najlepsze. Wysyłamy dokładnie to samo żądanie co wcześniej, z "isAdmin": true:
HTTP/1.1 201 Created
Location: https://localhost:5001/api/users/43
{
"id": 43,
"email": "jan@example.com"
}201 Created, użytkownik utworzony... ale isAdmin zostało kompletnie zignorowane, bo CreateUserRequest w ogóle nie ma takiego pola (System.Text.Json domyślnie pomija nieznane właściwości). A hasło? Zahashowane i nie wraca w odpowiedzi. Ta sama funkcjonalność, zero dziur.
Ściąga: kod tutorialowy vs produkcyjny
Zbierzmy to w jedną ściągę. Warto ją zapisać, bo to dokładnie ta lista, o którą pytają na rozmowach:
| Tutorial | Produkcja |
| Encja w żądaniu i odpowiedzi | DTO: osobny Request i Response |
| Brak walidacji | Walidacja na wejściu, zanim cokolwiek dotknie bazy |
| Kod synchroniczny | async/await + CancellationToken |
| Hasło zapisywane i zwracane otwartym tekstem | Hash hasła, który nigdy nie trafia do odpowiedzi |
| return Ok() na wszystko | Poprawne kody HTTP: 201, 400, 409 |
| Wyjątek z bazy = 500, a w najgorszym razie stack trace | Sprawdzenie konfliktu + globalna obsługa wyjątków (ProblemDetails) |
| Brak logów | Logowanie kluczowych operacji |
I teraz najważniejsze: na rozmowie kwalifikacyjnej nikt nie zapyta Cię, jak napisać endpoint. Pokażą Ci wersję z tutoriala i zapytają: "co tu jest nie tak?". Jeśli wymienisz problemy z lewej kolumny i powiesz, jak je naprawić, jesteś w zupełnie innej lidze niż większość kandydatów.
Błąd #3: tylko happy path
Tutorial pokazuje świat, w którym użytkownik zawsze wpisze poprawne dane, internet zawsze działa, a baza nigdy nie pada. Prosta zielona linia z A do B, czyli tzw. happy path.
A produkcja? Produkcja to miejsce, gdzie użytkownik wklei całą "Panią Twardowską" w pole numeru telefonu. Null, timeout, baza padła, emoji w polu daty. W pracy mniej więcej 20% czasu piszesz nowy kod, a 80% to obsługa przypadków, które "nigdy nie wystąpią". A wystąpią. Zawsze występują.
First vs FirstOrDefault. Zobacz to na przykładzie. Tak pobiera użytkownika tutorial:
/* tutorial */
var user = await _context.Users.FirstAsync(u => u.Id == id);Senior od razu zada kilka pytań:
1. Co, jeśli użytkownika o tym id nie ma? FirstAsync rzuca wtedy wyjątek InvalidOperationException.
2. Czy na pewno chcesz wyjątek i błąd 500, a nie 404?
3. Co zobaczy użytkownik? Co trafi do logów?
Wersja z pracy:
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id, CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, ct);
if (user is null)
return NotFound();
return Ok(new UserResponse(user.Id, user.Email));
}FirstOrDefaultAsync, sprawdzenie nulla, czysty NotFound. Różnica to dosłownie kilka linijek, ale to różnica między "przepisuję tutoriale" a "myślę jak programista". Przy okazji: to właśnie do tej akcji GetUser odwołuje się CreatedAtAction z poprzedniego przykładu.
Nawyk na co dzień. Przy każdej linijce kodu zapytaj na głos: co tu może pójść nie tak? Brzmi głupio. Działa genialnie. Na start wystarczy kilka pytań kontrolnych:
• Co, jeśli rekordu nie ma albo wartość jest nullem?
• Co, jeśli dane są puste, za długie albo w złym formacie?
• Co, jeśli baza albo zewnętrzne API odpowiada wolno lub wcale?
• Co, jeśli to samo żądanie przyjdzie 2 razy (np. po podwójnym kliknięciu)?
Błąd #4: zawsze zaczynasz od zera, nigdy nie czytasz cudzego kodu
Tutoriale zawsze zaczynają się tak samo: dotnet new, pusty projekt, zielona łąka. A wiesz, jak wygląda pierwszy dzień w pracy? Dostajesz projekt z 300 000 linii kodu, pisany przez 15 osób przez 8 lat. Autorów połowy tego kodu nie ma już w firmie, a do końca nie rozumie go nikt. I Twoje zadanie brzmi: napraw buga, niczego nie psując. Witamy w legacy.
W pracy prawie nigdy nie zaczynasz od zera. Mniej więcej 95% czasu pracujesz w kodzie, którego nie napisałeś. Czytanie kodu to osobna umiejętność. I nikt Cię jej nie uczy.
Ćwiczenie na ten tydzień. Jak trenować czytanie kodu za darmo? Open source. Konkretne zadanie:
1. Otwórz eShop - referencyjną aplikację Microsoftu: github.com/dotnet/eShop.
2. Znajdź, gdzie jest tworzone zamówienie. Bez czytania całości: szukaj po nazwach, śledź wywołania, korzystaj z "Go to definition" i "Find all references" (najwygodniej po sklonowaniu repozytorium i otwarciu go w Visual Studio albo Riderze).
3. Narysuj na kartce, jakie klasy biorą w tym udział.
4. Bonus: znajdź 1 test, który to sprawdza.
Pierwszy raz zajmie Ci to godzinę albo 2 i będziesz sfrustrowany. Dziesiąty raz - 10 minut. I właśnie ta umiejętność, a nie znajomość składni, decyduje o tym, czy przetrwasz okres próbny.
Plan naprawczy: 5 kroków zamiast kolejnego kursu
Na koniec mapa na najbliższe tygodnie. 5 kroków:
1. Stop z nowymi kursami. Masz już za dużo teorii.
2. Zbuduj 1 projekt bez tutoriala. To najważniejszy punkt. Mały, ale skończony i wdrożony (np. na Azure albo Railway). Z walidacją, obsługą błędów i logami - wszystkim, o czym była mowa w tym artykule.
3. Dodaj testy. Chociaż 10 testów jednostkowych. Zdecydowana większość juniorów nie ma ich w portfolio, więc od razu się wyróżnisz.
4. Czytaj cudzy kod 30 minut dziennie. Open source, GitHub - choćby wspomniany eShop.
5. Wystaw swój kod na krytykę. Code review od kogoś doświadczonego boli, ale uczy najszybciej.
Zauważ, że ani 1 punkt z tej listy nie brzmi "obejrzyj kolejny kurs". Bo problem nie leży w wiedzy. Wiedza z tutoriali jest pasywna, a rekrutacja i praca sprawdzają wiedzę aktywną.
Jeden porządny projekt, zrobiony samodzielnie, z błędami, przekleństwami i debugowaniem do 2 w nocy, nauczy Cię więcej niż 5 kursów. I co ważniejsze: na rozmowie będziesz umiał o nim opowiadać godzinami, bo to Twoje decyzje, Twoje błędy i Twoje rozwiązania. Tego nie da się podrobić.
Podsumowanie
Tutoriale nie są złe - są niekompletne. Uczą składni, ale nie uczą tego, co sprawdza rekrutacja:
• Myślenia zamiast przepisywania: odtwarzaj projekty z pamięci, dodawaj własne funkcje i trzymaj się zasady 1:3.
• Pisania kodu produkcyjnego: DTO zamiast encji, walidacja na wejściu, async z CancellationToken, hashowanie haseł, poprawne kody HTTP i logi.
• Myślenia o przypadkach brzegowych: przy każdej linijce pytaj "co tu może pójść nie tak?".
• Czytania cudzego kodu: open source, 30 minut dziennie.
• Radzenia sobie z frustracją: to znak, że się uczysz, a nie że się nie nadajesz.
Dobra wiadomość? Wszystko to da się wytrenować. Zacznij od punktu 1 planu naprawczego: zamknij zakładki z kursami i otwórz pusty projekt.
Jeśli chcesz iść tą drogą ze mną, dołącz do mojej społeczności .NET - jest nas już ponad 10 000 programistów. Raz w tygodniu wysyłam jednego konkretnego maila: najważniejsze newsy ze świata .NET, praktyczne wskazówki takie jak w tym artykule, których nie publikuję nigdzie indziej, i rabaty na moje kursy i szkolenia, niedostępne w żadnym innym miejscu. Zero spamu, a wypisujesz się jednym kliknięciem.
Jeśli chcesz dołączyć, zostaw swój adres tutaj: modestprogrammer.pl/vip. Powodzenia - i do zobaczenia po drugiej stronie tutorial hell :)