10 pytań rekrutacyjnych

10 pytań rekrutacyjnych Junior .NET Developer – wzorcowe odpowiedzi z kodem

Idziesz na rozmowę rekrutacyjną na stanowisko Junior .NET Developer i chcesz wiedzieć, czego się spodziewać? Dobrze trafiłeś.

Poniżej znajdziesz 10 pytań, które padają na niemal każdej rozmowie — od interfejsów i Dependency Injection, przez async/await, aż po zasadę SRP. Do każdego pytania dostajesz:

  • Złą odpowiedź — to, co mówi większość kandydatów (poprawne, ale niepełne).
  • Wzorcową odpowiedź — z działającym kodem C# i testami xUnit.
  • 💡 Wskazówkę — zdanie, które robi różnicę w oczach rekrutera.

📥 Cały kod źródłowy (solution Visual Studio) pobierzesz z repozytorium GitHub. Uruchom dotnet test i sprawdź, że wszystkie 22 testy są zielone.


Spis treści

  1. Interface vs Abstract class
  2. Dependency Injection
  3. Record vs Class
  4. async/await i ConfigureAwait
  5. LINQ i lazy evaluation
  6. Value types vs Reference types
  7. Generics i constraints
  8. Wyjątki vs kody błędów
  9. IDisposable i using
  10. SRP — Single Responsibility Principle

1. Interface vs Abstract class

Pytanie rekrutera: Czym różni się interface od abstract class? Kiedy użyjesz jednego, a kiedy drugiego?

Zła odpowiedź

„Interface nie ma implementacji, abstract class może mieć.”

Technicznie poprawne — ale rekruter zaznacza w notatce: „zna definicję, nie rozumie zastosowania”. Brakuje odpowiedzi na kluczowe pytanie: kiedy co wybrać?

Wzorcowa odpowiedź

Interface definiuje KONTRAKT — mówi co coś robi, nie jak. Używaj go, kiedy różne, niepowiązane klasy muszą spełnić ten sam kontrakt:

// KONTRAKT — różne klasy, ten sam interfejs
public interface IEmailService
{
    Task SendAsync(string to, string subject, string body);
}

// Dwie niezależne implementacje — łączy je kontrakt, nie hierarchia
public class SmtpEmailService : IEmailService
{
    public Task SendAsync(string to, string subject, string body)
    {
        Console.WriteLine($"[SMTP] {to}: {subject}");
        return Task.CompletedTask;
    }
}

public class SendGridEmailService : IEmailService
{
    public Task SendAsync(string to, string subject, string body)
    {
        Console.WriteLine($"[SendGrid] {to}: {subject}");
        return Task.CompletedTask;
    }
}

Abstract class definiuje SZKIELET — wspólne zachowanie plus wymuszony override. Używaj, gdy klasy są ze sobą powiązane i dzielą wspólny kod:

// SZKIELET — wspólny kod + wymuszony override
public abstract class BaseRepository<T> where T : class
{
    protected readonly List<T> _store = new();     // wspólne dla każdego repo

    public abstract T? GetById(int id);            // wymuszony override
    public abstract void Add(T entity);            // wymuszony override

    public virtual IReadOnlyList<T> GetAll()       // gotowa implementacja
        => _store.AsReadOnly();

    public void AddRange(IEnumerable<T> entities)  // wspólna logika
    {
        foreach (var e in entities) Add(e);
    }
}

Reguła do zapamiętania

InterfaceAbstract class
Mogę to zrobić” — kontraktJestem tym” — wspólna tożsamość
Klasy mają różne origin, to samo zachowanieKlasy są tym samym, różnią się szczegółami
Łatwe mockowanie w testachWymaga konkretnej implementacji

💡 Wskazówka na rozmowę:
„W C# 8 interface może mieć domyślną implementację — ważne przy rozszerzaniu API bez łamania istniejących klas. W nowoczesnym C# preferuję interface plus composition over inheritance.”

📖 Zobacz też: Wzorzec repozytorium w C# — jak poprawnie zaimplementować Repository Pattern


2. Dependency Injection

Pytanie rekrutera: Co to jest Dependency Injection i jakie są czasy życia serwisów w ASP.NET Core?

Zła odpowiedź

„DI to wzorzec projektowy, gdzie przekazujemy zależności przez konstruktor zamiast tworzyć je w klasie.”

Poprawne, ale brakuje: dlaczego? Co zyskujemy? Jak wygląda to w ASP.NET Core?

Wzorcowa odpowiedź — najpierw problem

// ❌ BEZ DI — tight coupling, brak testowalności
public class OrderServiceBad
{
    // Klasa SAMA tworzy swoje zależności
    // Nie można podmienić bez zmiany kodu
    // Nie można przetestować bez bazy i SMTP
    private readonly SqlOrderRepositoryBad _repository = new();
    private readonly SmtpEmailServiceBad   _email      = new();

    public void PlaceOrder(int userId, decimal amount)
    {
        _repository.Save(userId, amount);
        _email.Send(userId, amount);
    }
}

✅ Wzorcowa odpowiedź — rozwiązanie z DI

// Krok 1: Zdefiniuj interfejsy (kontrakty)
public interface IOrderRepository
{
    void Save(int userId, decimal amount);
    IReadOnlyList<(int UserId, decimal Amount)> GetAll();
}

public interface IEmailNotifier
{
    void SendConfirmation(int userId, decimal amount);
}

// Krok 2: Serwis zależy od INTERFEJSÓW, nie od klas
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailNotifier   _notifier;

    // ✅ Konstruktor przyjmuje interfejsy
    // Można przekazać SqlRepo, MongoRepo, InMemoryRepo — bez zmian w kodzie
    public OrderService(IOrderRepository repository, IEmailNotifier notifier)
    {
        _repository = repository;
        _notifier   = notifier;
    }

    public void PlaceOrder(int userId, decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be > 0");

        _repository.Save(userId, amount);
        _notifier.SendConfirmation(userId, amount);
    }
}

Trzy czasy życia w ASP.NET Core

To pytanie prawie zawsze pada jako follow-up. Jeśli sam wspomnisz czasy życia bez pytania — rekruter robi notatkę:

Czas życiaKiedy nowa instancja?Typowe użycie
TransientPrzy każdym żądaniu o serwis (resolve)Lekkie serwisy bezstanowe
ScopedJedna instancja na HTTP requestDbContext, serwisy z danymi żądania
SingletonJedna instancja na cały czas życia aplikacjiCache, konfiguracja — musi być thread-safe!
// Rejestracja w Program.cs (ASP.NET Core)
builder.Services.AddTransient<IEmailNotifier, SmtpEmailNotifier>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();

Test dowodzący wartość DI

[Fact]
public void OrderService_PlaceOrder_SavesAndSendsEmail()
{
    // Test BEZ bazy danych i BEZ serwera SMTP — to jest wartość DI
    var repo     = new InMemoryOrderRepository();
    var notifier = new FakeEmailNotifier();
    var service  = new OrderService(repo, notifier);

    service.PlaceOrder(userId: 1, amount: 299.99m);

    Assert.Single(repo.GetAll());           // zapis nastąpił
    Assert.Single(notifier.SentEmails);     // email wysłany
}

💡 Wskazówka:
Najczęstszą pułapką jest wstrzyknięcie serwisu Scoped do Singletona — Scoped zostanie de facto Singletonem (tzw. captive dependency). ASP.NET Core w trybie Development rzuci wyjątek, jeśli to wykryje.

📖 Zobacz też: Dependency Injection w ASP.NET Core


3. Record vs Class

Pytanie rekrutera: Kiedy użyjesz record zamiast class?

Zła odpowiedź

„Record jest immutable, a class jest mutable.”

Niekompletne. Record to znacznie więcej niż immutability — a class też może być immutable.

Wzorcowa odpowiedź — value semantics

Kluczowa różnica to tożsamość:

// CLASS — tożsamość przez REFERENCJĘ
var a1 = new AddressClass { Street = "Marszałkowska 1", City = "Warszawa" };
var a2 = new AddressClass { Street = "Marszałkowska 1", City = "Warszawa" };
Console.WriteLine(a1 == a2);  // FALSE — dwa różne obiekty

// RECORD — tożsamość przez WARTOŚĆ (value semantics)
var r1 = new Address("Marszałkowska 1", "Warszawa");
var r2 = new Address("Marszałkowska 1", "Warszawa");
Console.WriteLine(r1 == r2);  // TRUE — te same wartości!

// with-expression: kopia z jedną zmianą (immutability)
var r3 = r1 with { City = "Kraków" };
// r1 niezmieniony, r3 to nowy obiekt

Kiedy co wybrać?

Użyj recordUżyj class
DTO — dane między warstwamiEncje domenowe (tożsamość przez ID)
Komendy i zapytania CQRSSerwisy, repozytoria, kontrolery
Value Objects w DDD (Money, Email)Cokolwiek, co zmienia stan w czasie
Eventy domenoweHandlery, middleware

Value Object z rekordem — produkcyjny przykład

public record Money
{
    public decimal Amount   { get; init; }
    public string  Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be > 0", nameof(amount));
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency required", nameof(currency));
        Amount   = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                $"Cannot add {Currency} and {other.Currency}");
        return this with { Amount = Amount + other.Amount };
    }
}

💡 Reguła: Jeśli dwa obiekty z tymi samymi danymi powinny być równe — record. Jeśli obiekt ma tożsamość niezależną od swoich pól (ID w bazie) — class.

📖 Zobacz też: Value Object w DDD — implementacja w C# z rekordem


4. async/await i ConfigureAwait

Pytanie rekrutera: Co to jest async/await? Co się stanie bez ConfigureAwait(false)?

Zła odpowiedź

„async/await służy do asynchronicznego programowania, żeby aplikacja się nie blokowała.”

Poprawne, ale płytkie. Rekruter chce wiedzieć o mechanizmie zwalniania wątku, SynchronizationContext i praktycznych pułapkach.

Wzorcowa odpowiedź — co naprawdę robi await

Kiedy wykonanie dochodzi do await, wątek jest zwalniany z powrotem do puli wątków. Nie czeka, nie blokuje się — wraca do puli i może obsłużyć inne żądanie HTTP.

Gdy operacja I/O się zakończy (np. odpowiedź z bazy), system bierze wolny wątek z puli (niekoniecznie ten sam) i kontynuuje wykonanie od linijki po await.

Dlatego async nie przyspiesza jednego żądania — ale pozwala obsłużyć więcej żądań jednocześnie, bo wątki nie marnują się na czekanie.

public async Task<string> GetOrderAsync(int orderId)
{
    // ↓ Tutaj wątek jest ZWALNIANY — może obsłużyć inne żądania
    var response = await _http.GetAsync($"/api/orders/{orderId}");

    // ↓ Tutaj wątek WRACA (niekoniecznie ten sam) i kontynuuje
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync();
}

Deadlock — klasyczna pułapka

// ❌ DEADLOCK w WPF/WinForms/ASP.NET Classic!
public static string GetDataBad()
{
    // Krok 1: .Result BLOKUJE bieżący wątek
    // Krok 2: await kończy operację I/O
    // Krok 3: SynchronizationContext chce wrócić na zablokowany wątek
    // Krok 4: DEADLOCK — wątek czeka na Task, Task czeka na wątek
    return FetchDataAsync().Result;
}

// ✅ ConfigureAwait(false) — kontynuuj na DOWOLNYM wątku z puli
public static async Task<string> GetDataGoodAsync()
{
    return await FetchDataAsync().ConfigureAwait(false);
}

Uwaga:
W ASP.NET Core nie ma SynchronizationContext, więc deadlock z .Result technicznie nie wystąpi. Ale .Result i tak blokuje wątek z puli — pod obciążeniem zabije throughput Twojej aplikacji.

Trzy żelazne reguły async

  1. async all the way — jeśli jeden poziom jest async, całe call stack powinno być async.
  2. Nigdy async void — łyka wyjątki bez śladu. Wyjątek: event handlers w WPF/WinForms.
  3. Task.WhenAll zamiast sequential await — równoległe operacje I/O.
// ✅ Równoległe pobieranie — nie czekaj na jedno, żeby zacząć drugie
public async Task<(string Orders, string Users)> FetchBothAsync()
{
    var ordersTask = FetchOrdersAsync();
    var usersTask  = FetchUsersAsync();
    await Task.WhenAll(ordersTask, usersTask);
    return (await ordersTask, await usersTask);
}

💡 Wskazówka na rozmowę:
„CancellationToken — zawsze przyjmuję w sygnaturze metody async i zawsze przekazuję dalej. Jeśli o tym wspomnisz bez pytania, rekruter wie, że pisałeś produkcyjny kod.”

📖 Zobacz też: async/await w C# — kompletny przewodnik z pułapkami


5. LINQ i lazy evaluation

Pytanie rekrutera: Czym jest LINQ? Jak działa lazy evaluation?

Zła odpowiedź

„LINQ to zapytania do kolekcji, jak SQL.”

Brakuje tego, co najważniejsze: lazy evaluation (odroczone wykonanie) i pułapka multiple enumeration.

Wzorcowa odpowiedź

Kiedy piszesz .Where().OrderBy(), żadna iteracja się nie odbywa. Tworzysz plan zapytania — obiekt, który pamięta: „gdy ktoś mnie poprosi o elementy, przefiltruję i posortuję”.

Dopiero kiedy wywołasz metodę wymagającą danych — plan się wykonuje. To jest deferred execution:

var products = GetProducts();

// ↓ LAZY — zapytanie NIE wykonuje się tutaj. To tylko definicja.
var expensiveQuery = products
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Name);

Console.WriteLine("Zapytanie zdefiniowane — ale jeszcze nic się nie stało");

// ↓ MATERIALIZACJA — dopiero tutaj zapytanie się wykonuje
var result = expensiveQuery.ToList();

Pułapka: multiple enumeration

// ❌ Multiple enumeration — w EF Core to DWIE kwerendy SQL!
var filtered = products.Where(p => p.Price > 100);
var count = filtered.Count();   // 1. zapytanie do bazy
var first = filtered.First();   // 2. zapytanie — przy zmianach w DB może dać inny wynik!

// ✅ Zmaterializuj raz — potem operuj na liście w pamięci
var filteredList = products.Where(p => p.Price > 100).ToList();
var count2 = filteredList.Count;    // tylko pamięć — brak zapytania SQL
var first2 = filteredList.First();  // szybko i bezpiecznie

Operatory materializujące (kończą lazy evaluation)

💡 Wskazówka:
„Na rozmowie pokaż, że znasz różnicę LINQ-to-Objects vs LINQ-to-Entities. To samo Where() w pamięci to delegat C#, ale w EF Core to wyrażenie, które przekłada się na SQL. Nie każdy operator jest wspierany po stronie bazy.”

📖 Zobacz też: EF Core — N+1 problem i jak go unikać z Include


6. Value types vs Reference types

Pytanie rekrutera: Czym różnią się value types od reference types?

Zła odpowiedź

„Value types są na stosie, reference types na stercie.”

To jest uproszczenie. Value type jako pole klasy trafia na stertę razem z obiektem nadrzędnym. Kluczowa różnica to semantyka kopiowania, nie lokalizacja w pamięci.

Wzorcowa odpowiedź

// VALUE TYPE — struct
// Każda zmienna ma WŁASNĄ KOPIĘ danych
var s1 = new PointStruct { X = 1, Y = 2 };
var s2 = s1;   // KOPIA — niezależna od s1
s2.X = 99;
Console.WriteLine(s1.X); // 1 — oryginał NIEZMIENIONY

// REFERENCE TYPE — class
// Zmienne współdzielą TEN SAM obiekt na stercie
var r1 = new PointClass { X = 1, Y = 2 };
var r2 = r1;   // TA SAMA referencja — r2 wskazuje na r1
r2.X = 99;
Console.WriteLine(r1.X); // 99 — oryginał ZMIENIONY!

Kiedy struct zamiast class?

Kryteriastruct class
RozmiarMały (< 16 bajtów)Dowolny
MutacjaImmutable po stworzeniuZmienia stan w czasie
TworzenieMiliony instancji (brak presji GC)Normalna liczba
DziedziczenieNie potrzebujeszPotrzebujesz
SemantykaValue semantics (Point, Color)Tożsamość przez referencję

💡 Reguła:
Przy value type zmiana jednej zmiennej nie dotyka drugiej. Przy reference type wiele zmiennych wskazuje na ten sam obiekt — zmiana przez jedną jest widoczna przez wszystkie.

📖 Zobacz też: Boxing i unboxing w C# — ukryty koszt wydajności


7. Generics i constraints

Pytanie rekrutera: Co to są Generics? Do czego służą constraints?

Zła odpowiedź

„Generics to coś jak szablony — można użyć dowolnego typu zamiast pisać osobną klasę dla każdego.”

Kierunek dobry, ale brakuje wyjaśnienia dlaczego constraints są potrzebne i co dają kompilatorowi.

Wzorcowa odpowiedź — problem bez Generics

// ❌ Bez Generics — osobna klasa dla każdego typu
public class IntStack
{
    private readonly List<int> _items = new();
    public void Push(int item) => _items.Add(item);
    public int Pop() { /* ... */ }
}
// Chcesz Stack<string>? Piszesz kolejną klasę.
// Albo używasz object — tracisz type safety + masz boxing.

✅ Z Generics — jeden kod, wiele typów

public class Stack<T>
{
    private readonly List<T> _items = new();

    public void Push(T item) => _items.Add(item);

    public T Pop()
    {
        if (_items.Count == 0)
            throw new InvalidOperationException("Stack is empty.");
        var item = _items[^1];
        _items.RemoveAt(_items.Count - 1);
        return item;
    }

    public int Count => _items.Count;
}

// Ten sam kod — dwa różne typy, pełne type safety
var intStack    = new Stack<int>();
var stringStack = new Stack<string>();

Constraints — mówią kompilatorowi, co T potrafi

Bez constraints kompilator wie o T tyle co o object — prawie nic. Constraints odbllokowują możliwości:

public class Repository<T> where T : class, IEntity, new()
{
    // where T : class   → T jest typem referencyjnym (może być null)
    // where T : IEntity → T ma właściwość Id — mogę po niej szukać
    // where T : new()   → T ma bezparametrowy konstruktor — mogę zrobić new T()

    private readonly List<T> _store = new();

    public T? FindById(int id) => _store.FirstOrDefault(e => e.Id == id);
    public T CreateNew() => new T();  // możliwe TYLKO dzięki where T : new()
}

Result<T> — Generics w produkcyjnym kodzie

public class Result<T>
{
    public T?      Value   { get; private init; }
    public string? Error   { get; private init; }
    public bool    IsSuccess => Error is null;

    public static Result<T> Ok(T value)    => new() { Value = value };
    public static Result<T> Fail(string e) => new() { Error = e };

    // Map — łańcuchowanie operacji (jak w funkcyjnym programowaniu)
    public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
        IsSuccess ? Result<TNew>.Ok(mapper(Value!)) : Result<TNew>.Fail(Error!);
}

// Użycie:
var result = Result<int>.Ok(5)
    .Map(x => x * 2)
    .Map(x => $"Wynik: {x}");
// result.Value == "Wynik: 10"

💡 Wskazówka:
„Na rozmowie wspomnij o covariance/contravariance (out/in). IEnumerable<out T> jest kowariantny — możesz przypisać IEnumerable<Dog> do IEnumerable<Animal>. To pokazuje głębsze rozumienie Generics.”

📖 Zobacz też: Result Pattern w C# — obsługa błędów bez wyjątków


8. Wyjątki vs kody błędów

Pytanie rekrutera: Kiedy używać wyjątków, a kiedy zwracać kody błędów / Result?

Zła odpowiedź

„Wyjątki służą do obsługi błędów.”

Rekruter chce wiedzieć: kiedy rzucać a kiedy zwracać Result / null / bool.

Wzorcowa odpowiedź — granica jest prosta

Zadaj sobie pytanie: czy caller mógł temu zapobiec?

  • TAK → rzuć wyjątek. To BUG w kodzie callera.
  • NIE → zwróć null, Result.Fail() lub bool TryGet(…). To normalny stan biznesowy.
public class ExceptionGuidelines
{
    // ✅ Rzucaj: caller przekazał null — to BUG callera
    public void ProcessOrder(Order? order)
    {
        ArgumentNullException.ThrowIfNull(order);

        if (order.Amount <= 0)
            throw new ArgumentOutOfRangeException(
                nameof(order.Amount), "Amount must be > 0");

        if (order.Status == OrderStatus.Cancelled)
            throw new InvalidOperationException(
                $"Cannot process cancelled order #{order.Id}");
    }

    // ✅ Zwracaj null: brak zamówienia to normalny case, nie bug
    public Order? FindOrder(int id)
        => _orders.FirstOrDefault(o => o.Id == id);
}

Własna hierarchia wyjątków

// Bazowy wyjątek domenowy
public class DomainException(string message) : Exception(message);

// Wyjątek z kontekstem — OrderId jest dostępny bez parsowania Message
public class OrderNotFoundException(int orderId)
    : DomainException($"Order #{orderId} not found.")
{
    public int OrderId { get; } = orderId;
}

Zasady łapania wyjątków

  • Łap selektywnie — tylko to, co umiesz obsłużyć.
  • Zawsze loguj — catch bez logowania to zamiatanie pod dywan.
  • Nigdy pusty catch {} — najgorszy antywzorzec w C#.
  • Nigdy throw new Exception(“błąd”) — brak kontekstu, brak typu.

💡 Wskazówka:
„Wyjątki są kosztowne (~100x wolniejsze niż return). W hot path (walidacja, parsowanie) nigdy nie steruj flow wyjątkami. Dlatego istnieje int.TryParse() zamiast int.Parse() + catch.”

📖 Zobacz też: Global Exception Handling w ASP.NET Core — middleware i ProblemDetails


9. IDisposable i using

Pytanie rekrutera: Co to jest IDisposable? Do czego służy using?

Zła odpowiedź

„IDisposable to interfejs z metodą Dispose(), a using automatycznie ją wywołuje.”

Poprawne, ale brakuje dlaczego to jest potrzebne: GC zarządza pamięcią zarządzaną automatycznie. Ale zasoby niezarządzane — pliki, połączenia DB, sockety, handlery — trzeba zwalniać ręcznie.

Wzorcowa odpowiedź — implementacja wzorca Dispose

public class DatabaseConnection : IDisposable
{
    private bool _disposed = false;

    public DatabaseConnection(string connectionString)
    {
        Console.WriteLine($"[DB] Connection opened");
    }

    public void ExecuteQuery(string sql)
    {
        // ✅ Po Dispose obiekt INFORMUJE o złym użyciu
        ObjectDisposedException.ThrowIf(_disposed, nameof(DatabaseConnection));
        Console.WriteLine($"[DB] Executing: {sql}");
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this); // Finalizator niepotrzebny
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Zwolnij zasoby zarządzane (inne IDisposable)
            Console.WriteLine("[DB] Connection closed.");
        }
        // Zwolnij zasoby niezarządzane (handles, native memory)
        _disposed = true;
    }
}

using — gwarancja zwolnienia zasobów

// ✅ using statement — Dispose() GWARANTOWANY, nawet gdy wyjątek
using var conn = new DatabaseConnection("Server=localhost;...");
conn.ExecuteQuery("SELECT * FROM Orders");
// Dispose() wywoła się automatycznie na końcu scope

// ❌ BEZ using — connection leak!
var badConn = new DatabaseConnection("Server=localhost;...");
badConn.ExecuteQuery("SELECT 1");
// Dispose() NIGDY nie zostanie wywołany (chyba że finalizator — za późno)

Test potwierdzający

[Fact]
public void DatabaseConnection_AfterDispose_ThrowsObjectDisposed()
{
    var conn = new DatabaseConnection("Server=test;...");
    conn.Dispose();

    // Po Dispose — jasna informacja zamiast cichego crasha
    Assert.Throws<ObjectDisposedException>(
        () => conn.ExecuteQuery("SELECT 1"));
}

💡 Wskazówka: „IAsyncDisposable i await using (C# 8+) służą do asynchronicznego cleanup — np. zamykanie async połączenia DB, flush bufora. DbContext w EF Core implementuje oba interfejsy. W kontrolerze async używasz await using, w teście synchronicznym — zwykłe using.”

📖 Zobacz też: DbContext w EF Core — czasy życia, Dispose i pułapki


10. SRP — Single Responsibility Principle

Pytanie rekrutera: Opisz zasadę SRP i podaj przykład jej złamania.

Zła odpowiedź

„Klasa powinna robić tylko jedną rzecz.”

Zbyt ogólne. Rekruter pyta: „A co to znaczy ‘jedna rzecz’?” Lepsza definicja: klasa powinna mieć tylko jeden powód do zmiany.

Wzorcowa odpowiedź — przykład złamania SRP

// ❌ SRP ZŁAMANE — 4 powody do zmiany, 4 aktorów
public class UserServiceBad
{
    public void Register(string email, string password)
    {
        // 1. Walidacja          → zmienia: dział UX
        if (!email.Contains('@'))
            throw new ArgumentException("Invalid email");

        // 2. Hashowanie hasła   → zmienia: zespół security
        var hash = Convert.ToBase64String(
            SHA256.HashData(Encoding.UTF8.GetBytes(password)));

        // 3. Zapis do bazy      → zmienia: DBA
        Console.WriteLine($"INSERT INTO Users ('{email}', '{hash}')");

        // 4. Wysyłka emaila     → zmienia: marketing
        Console.WriteLine($"Welcome email sent to {email}");
    }
}

Pytanie testowe: Kto zmienia tę klasę? — dział UX, DBA, marketing, security. Czterech aktorów = SRP złamane.

Rozwiązanie — jeden powód do zmiany na klasę

// Każda klasa odpowiada JEDNEMU aktorowi
public class UserValidator { /* zmienia: UX */ }
public class Sha256PasswordHasher : IPasswordHasher { /* zmienia: security */ }
public class SqlUserRepository : IUserRepository { /* zmienia: DBA */ }
public class SmtpWelcomeEmailSender : IWelcomeEmailSender { /* zmienia: marketing */ }

// ✅ Orkiestrator — zmienia się TYLKO gdy zmienia się FLOW rejestracji
public class UserRegistrationService(
    UserValidator validator,
    IPasswordHasher hasher,
    IUserRepository repository,
    IWelcomeEmailSender emailSender)
{
    public async Task RegisterAsync(RegisterUserCommand cmd)
    {
        validator.Validate(cmd);

        if (repository.Exists(cmd.Email))
            throw new InvalidOperationException($"User '{cmd.Email}' already exists.");

        var hash = hasher.Hash(cmd.Password);
        repository.Save(cmd.Email, hash);
        await emailSender.SendAsync(cmd.Email);
    }
}

Praktyczne sygnały złamania SRP

  • ⚠️ Metoda ma więcej niż 20–30 linii.
  • ⚠️ Klasa ma więcej niż 3–4 zależności w konstruktorze.
  • ⚠️ Komentarze „Część 1: walidacja // Część 2: zapis // Część 3: email”.
  • ⚠️ Trudno napisać unit test bez mockowania 4+ klas.
  • ⚠️ Pytanie „kto zmienia tę klasę?” ma więcej niż jedną odpowiedź.

💡 Wskazówka: „SRP nie znaczy, że klasa ma jedną metodę — znaczy, że wszystkie jej metody zmieniają się z tego samego powodu. UserValidator może mieć 10 metod walidacji — ale wszystkie zmienia ten sam aktor (UX).”

📖 Zobacz też: SOLID w praktyce — 5 zasad z kodem C# i przykładami


Checklist: przed rozmową rekrutacyjną

Przed wejściem do pokoju (lub na Zooma) sprawdź, czy potrafisz odpowiedzieć na każde pytanie z przykładem kodu i uzasadnieniem:

  • [ ] Interface vs Abstract — wiesz kiedy co wybrać, nie tylko czym się różnią.
  • [ ] DI — potrafisz wymienić 3 korzyści i 3 czasy życia.
  • [ ] Record vs Class — rozumiesz value semantics i potrafisz podać with expression.
  • [ ] async/await — wiesz co robi await z wątkiem i czemu async void jest zły.
  • [ ] LINQ — wyjaśniasz lazy evaluation i pułapkę multiple enumeration.
  • [ ] Value vs Reference — demonstrujesz kopiowanie vs współdzielenie referencji.
  • [ ] Generics — wyjaśniasz constraints i co dają kompilatorowi.
  • [ ] Wyjątki — wiesz kiedy throw a kiedy return null / Result.
  • [ ] IDisposable — implementujesz wzorzec Dispose i wyjaśniasz using.
  • [ ] SRP — podajesz przykład złamania i naprawy z pytaniem „kto zmienia tę klasę?”.

Pobierz kod i ćwicz

Cały kod z tego artykułu jest dostępny jako solution Visual Studio z 22 testami xUnit:

git clone https://github.com/mariuszjurczenko/DevHobby.RecruitmentQA.git
cd DevHobby.RecruitmentQA
dotnet test

Wszystkie testy zielone = wzorcowy kod działa. Eksperymentuj — zmień coś w kodzie, zobacz czy testy dalej przechodzą.


Co dalej?

Te 10 pytań to fundament — ale na rozmowie rekrutacyjnej może paść dużo więcej. Jeśli chcesz systemowo przygotować się do pierwszej pracy jako .NET Developer:

🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy, z konkretnym planem co robić na każdym etapie.

🎓 Skorzystaj z 7 dni bezpłatnego dostępu do kursów — przejdź przez każdy krok z kursami wideo i code review.

🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.


Które pytanie było dla Ciebie najtrudniejsze? Napisz w komentarzu — czytam każdy! 💬


Zobacz też

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

👉 sprawdź moje kursy .NET i C#.

Dodaj komentarz