Questions d'entretien C# et .NET : Guide complet 2026

Les 25 questions d'entretien C# et .NET les plus fréquentes. LINQ, async/await, dependency injection, Entity Framework et bonnes pratiques avec réponses détaillées.

Questions d'entretien C# et .NET - Guide complet

Les entretiens C# et .NET évaluent la maîtrise du langage, la compréhension de l'écosystème Microsoft et la capacité à concevoir des applications robustes et performantes. Ce guide couvre les questions essentielles, des fondamentaux du langage jusqu'aux patterns avancés d'architecture.

Conseil pour l'entretien

Les recruteurs valorisent les réponses qui démontrent une compréhension des mécanismes internes de .NET, pas seulement la syntaxe. Expliquer le "pourquoi" derrière chaque concept fait la différence.

Fondamentaux C#

Question 1 : Quelle est la différence entre value types et reference types ?

Cette distinction fondamentale affecte l'allocation mémoire, les performances et le comportement lors des passages de paramètres.

ValueVsReference.cscsharp
// Démonstration des différences de comportement

// VALUE TYPES : stockés sur la Stack, copiés par valeur
struct Point
{
    public int X;
    public int Y;
}

// REFERENCE TYPES : stockés sur le Heap, copiés par référence
class Person
{
    public string Name;
}

public class Demo
{
    public static void Main()
    {
        // Value type : copie indépendante
        Point p1 = new Point { X = 10, Y = 20 };
        Point p2 = p1;  // Copie complète des valeurs
        p2.X = 100;     // Ne modifie PAS p1
        Console.WriteLine($"p1.X = {p1.X}"); // 10

        // Reference type : même objet en mémoire
        Person person1 = new Person { Name = "Alice" };
        Person person2 = person1;  // Copie de la référence
        person2.Name = "Bob";      // MODIFIE person1 aussi
        Console.WriteLine($"person1.Name = {person1.Name}"); // Bob

        // Cas particulier : string est immutable
        string s1 = "Hello";
        string s2 = s1;
        s2 = "World";  // Crée une nouvelle string
        Console.WriteLine($"s1 = {s1}"); // Hello
    }
}

Les value types (int, struct, enum) sont alloués sur la Stack et libérés automatiquement. Les reference types (class, interface, delegate) sont alloués sur le Heap et gérés par le Garbage Collector.

Question 2 : Expliquez le mot-clé ref, out et in

Ces modificateurs contrôlent comment les paramètres sont passés aux méthodes, avec des implications sur les performances et la mutabilité.

ParameterModifiers.cscsharp
// Les trois modificateurs de passage par référence

public class ParameterDemo
{
    // REF : la variable DOIT être initialisée avant l'appel
    // Peut être lue ET modifiée dans la méthode
    public static void ModifyWithRef(ref int value)
    {
        Console.WriteLine($"Valeur reçue: {value}");
        value = value * 2;  // Modification visible à l'appelant
    }

    // OUT : la variable n'a PAS besoin d'être initialisée
    // DOIT être assignée avant la fin de la méthode
    public static bool TryParse(string input, out int result)
    {
        // result DOIT être assigné dans tous les chemins d'exécution
        if (int.TryParse(input, out result))
        {
            return true;
        }
        result = 0;  // Assignation obligatoire
        return false;
    }

    // IN : passage par référence en lecture seule (C# 7.2+)
    // Évite la copie pour les gros structs sans permettre la modification
    public static double CalculateDistance(in Point3D p1, in Point3D p2)
    {
        // p1.X = 10; // ERREUR : ne peut pas modifier un paramètre 'in'
        return Math.Sqrt(
            Math.Pow(p2.X - p1.X, 2) +
            Math.Pow(p2.Y - p1.Y, 2) +
            Math.Pow(p2.Z - p1.Z, 2)
        );
    }

    public static void Main()
    {
        // Utilisation de ref
        int number = 5;
        ModifyWithRef(ref number);
        Console.WriteLine($"Après ref: {number}"); // 10

        // Utilisation de out
        if (TryParse("123", out int parsed))
        {
            Console.WriteLine($"Parsed: {parsed}"); // 123
        }

        // Utilisation de in (optimal pour les gros structs)
        var point1 = new Point3D(0, 0, 0);
        var point2 = new Point3D(3, 4, 0);
        var distance = CalculateDistance(in point1, in point2);
    }
}

public readonly struct Point3D
{
    public readonly double X, Y, Z;
    public Point3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
}

in est particulièrement utile pour les structs volumineux car il évite la copie tout en garantissant l'immutabilité. C'est un pattern courant dans le code haute performance.

Performance avec in

Utiliser in pour les structs de plus de 16 bytes améliore les performances en évitant la copie. Pour les petits structs, le passage par valeur reste plus efficace.

Question 3 : Comment fonctionne le Garbage Collector en .NET ?

Le GC .NET utilise un algorithme générationnel pour optimiser la gestion mémoire automatique.

GarbageCollectorDemo.cscsharp
// Comprendre le fonctionnement du GC

public class GCDemo
{
    public static void DemonstrateGenerations()
    {
        // Génération 0 : objets nouvellement alloués
        var shortLived = new byte[1000];
        Console.WriteLine($"Génération: {GC.GetGeneration(shortLived)}"); // 0

        // Forcer une collection pour promouvoir l'objet
        GC.Collect();
        Console.WriteLine($"Après GC: {GC.GetGeneration(shortLived)}"); // 1

        GC.Collect();
        Console.WriteLine($"Après 2ème GC: {GC.GetGeneration(shortLived)}"); // 2

        // Statistiques mémoire
        var info = GC.GetGCMemoryInfo();
        Console.WriteLine($"Heap total: {info.HeapSizeBytes / 1024 / 1024}MB");
    }

    // Pattern IDisposable pour les ressources non-managées
    public class DatabaseConnection : IDisposable
    {
        private IntPtr _nativeHandle;
        private bool _disposed = false;

        public DatabaseConnection()
        {
            _nativeHandle = AllocateNativeResource();
        }

        // Méthode Dispose publique
        public void Dispose()
        {
            Dispose(disposing: true);
            GC.SuppressFinalize(this);  // Évite l'appel au finalizer
        }

        // Pattern Dispose protégé
        protected virtual void Dispose(bool disposing)
        {
            if (!_disposed)
            {
                if (disposing)
                {
                    // Libérer les ressources managées
                }

                // Libérer les ressources non-managées
                if (_nativeHandle != IntPtr.Zero)
                {
                    FreeNativeResource(_nativeHandle);
                    _nativeHandle = IntPtr.Zero;
                }

                _disposed = true;
            }
        }

        // Finalizer (destructeur) - appelé par le GC si Dispose n'a pas été appelé
        ~DatabaseConnection()
        {
            Dispose(disposing: false);
        }

        private IntPtr AllocateNativeResource() => IntPtr.Zero;
        private void FreeNativeResource(IntPtr handle) { }
    }
}

// Utilisation recommandée avec using
public class Usage
{
    public void Example()
    {
        // C# 8+ : using declaration
        using var connection = new GCDemo.DatabaseConnection();
        // ... utilisation
        // Dispose() appelé automatiquement à la fin du scope
    }
}

Le GC collecte la Génération 0 fréquemment (millisecondes), la Génération 1 occasionnellement, et la Génération 2 rarement. Les objets LOH (Large Object Heap > 85KB) sont traités séparément.

LINQ et Collections

Question 4 : Quelle est la différence entre IEnumerable et IQueryable ?

Cette question est cruciale pour comprendre l'exécution différée et les performances des requêtes.

EnumerableVsQueryable.cscsharp
// Différences fondamentales d'exécution

public class LinqDemo
{
    public static void CompareExecution(AppDbContext context)
    {
        // IEnumerable : exécution EN MÉMOIRE (client-side)
        IEnumerable<Product> enumerable = context.Products.AsEnumerable();
        var filteredEnum = enumerable
            .Where(p => p.Price > 100)  // Filtrage en C#
            .ToList();
        // SQL généré : SELECT * FROM Products (TOUT chargé)

        // IQueryable : exécution côté BASE DE DONNÉES (server-side)
        IQueryable<Product> queryable = context.Products;
        var filteredQuery = queryable
            .Where(p => p.Price > 100)  // Traduit en SQL WHERE
            .ToList();
        // SQL généré : SELECT * FROM Products WHERE Price > 100

        // Composition de requêtes avec IQueryable
        var query = context.Products.AsQueryable();

        // Chaque opération ajoute à l'Expression Tree
        query = query.Where(p => p.IsActive);
        query = query.Where(p => p.CategoryId == 5);
        query = query.OrderBy(p => p.Name);

        // L'exécution se fait ICI, avec une seule requête SQL optimisée
        var results = query.ToList();
    }

    // Méthode générique qui fonctionne avec les deux
    public static IEnumerable<T> FilterByCondition<T>(
        IEnumerable<T> source,
        Func<T, bool> predicate)
    {
        return source.Where(predicate);
    }

    // Version optimisée pour IQueryable
    public static IQueryable<T> FilterByCondition<T>(
        IQueryable<T> source,
        Expression<Func<T, bool>> predicate)
    {
        // Expression<Func<>> permet la traduction en SQL
        return source.Where(predicate);
    }
}

Utiliser IQueryable avec Entity Framework pour que le filtrage soit fait côté base de données. IEnumerable convient pour les collections en mémoire ou quand toutes les données sont déjà chargées.

Question 5 : Expliquez l'exécution différée (deferred execution) en LINQ

L'exécution différée est un concept fondamental qui affecte les performances et le comportement des requêtes LINQ.

DeferredExecution.cscsharp
// Comprendre quand les requêtes s'exécutent réellement

public class DeferredExecutionDemo
{
    public static void Demonstrate()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };

        // La requête est DÉFINIE mais PAS EXÉCUTÉE
        var query = numbers.Where(n => {
            Console.WriteLine($"Évaluation de {n}");
            return n > 2;
        });

        Console.WriteLine("Requête définie, mais rien ne s'est passé");

        // Modification de la source AVANT l'exécution
        numbers.Add(6);
        numbers.Add(7);

        Console.WriteLine("Début de l'itération:");
        // L'EXÉCUTION se fait ICI lors de l'énumération
        foreach (var n in query)
        {
            Console.WriteLine($"Résultat: {n}");
        }
        // Sortie inclut 6 et 7 car ils ont été ajoutés avant l'exécution
    }

    // Méthodes qui FORCENT l'exécution immédiate
    public static void ImmediateExecution()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };

        // ToList(), ToArray(), ToDictionary() = exécution immédiate
        var list = numbers.Where(n => n > 2).ToList();

        // Count(), First(), Single(), Any() = exécution immédiate
        var count = numbers.Where(n => n > 2).Count();
        var first = numbers.First(n => n > 2);

        // Aggregate(), Sum(), Max(), Min() = exécution immédiate
        var sum = numbers.Where(n => n > 2).Sum();
    }

    // Danger : multiple énumération
    public static void MultipleEnumerationProblem()
    {
        var numbers = GetNumbers();  // IEnumerable retourné par yield

        // ⚠️ CHAQUE utilisation ré-exécute la requête
        var count = numbers.Count();   // 1ère énumération
        var first = numbers.First();   // 2ème énumération

        // ✅ Solution : matérialiser une fois
        var materializedList = numbers.ToList();
        var countOk = materializedList.Count;    // Pas de ré-exécution
        var firstOk = materializedList.First();  // Pas de ré-exécution
    }

    private static IEnumerable<int> GetNumbers()
    {
        Console.WriteLine("GetNumbers appelé");
        yield return 1;
        yield return 2;
        yield return 3;
    }
}
Multiple énumération

Utiliser un analyzer comme ReSharper ou Rider pour détecter les problèmes de multiple énumération qui peuvent causer des bugs subtils et des problèmes de performance.

Async/Await et Multithreading

Question 6 : Expliquez async/await et le fonctionnement des Tasks

L'asynchronisme est essentiel pour les applications modernes. Comprendre son fonctionnement interne démontre une expertise avancée.

AsyncAwaitDemo.cscsharp
// Mécanismes internes de l'asynchronisme

public class AsyncDemo
{
    // async transforme la méthode en state machine
    public async Task<string> FetchDataAsync(string url)
    {
        using var client = new HttpClient();

        // await libère le thread pendant l'attente I/O
        // Le thread retourne au pool et peut traiter d'autres requêtes
        var response = await client.GetStringAsync(url);

        // Après await, l'exécution reprend (possiblement sur un autre thread)
        return ProcessData(response);
    }

    // Pattern pour exécution parallèle
    public async Task<(User, List<Order>)> GetUserWithOrdersAsync(int userId)
    {
        // Les deux appels démarrent SIMULTANÉMENT
        var userTask = GetUserAsync(userId);
        var ordersTask = GetOrdersAsync(userId);

        // await attend les deux résultats
        await Task.WhenAll(userTask, ordersTask);

        return (userTask.Result, ordersTask.Result);
    }

    // ConfigureAwait pour les bibliothèques
    public async Task<string> LibraryMethodAsync()
    {
        // ConfigureAwait(false) évite de capturer le SynchronizationContext
        // Recommandé dans les bibliothèques pour éviter les deadlocks
        var data = await FetchDataAsync("https://api.example.com")
            .ConfigureAwait(false);

        return data.ToUpper();
    }

    // ❌ Anti-pattern : async void (sauf pour les event handlers)
    public async void BadAsyncMethod()
    {
        // Les exceptions ne peuvent pas être catchées
        // Impossible d'attendre la complétion
        await Task.Delay(100);
    }

    // ✅ Correct : async Task
    public async Task GoodAsyncMethod()
    {
        await Task.Delay(100);
    }

    private Task<User> GetUserAsync(int id) => Task.FromResult(new User());
    private Task<List<Order>> GetOrdersAsync(int id) => Task.FromResult(new List<Order>());
    private string ProcessData(string data) => data;
}

public class User { }
public class Order { }

Le compilateur transforme les méthodes async en state machines. Chaque await représente un point de suspension où le thread est libéré.

Question 7 : Comment éviter les deadlocks avec async/await ?

Les deadlocks asynchrones sont un piège classique, surtout dans les applications avec un SynchronizationContext (UI, ASP.NET classique).

DeadlockPrevention.cscsharp
// Patterns pour éviter les deadlocks

public class DeadlockDemo
{
    private readonly IDataService _service;

    // ❌ DEADLOCK dans ASP.NET classique ou WinForms/WPF
    public string GetDataDeadlock()
    {
        // .Result ou .Wait() bloque le thread UI/Request
        // async essaie de reprendre sur ce même thread = deadlock
        return _service.FetchAsync().Result;
    }

    // ✅ Solution 1 : async all the way
    public async Task<string> GetDataAsync()
    {
        return await _service.FetchAsync();
    }

    // ✅ Solution 2 : ConfigureAwait(false) dans la bibliothèque
    public async Task<string> FetchAsync()
    {
        var data = await HttpClient.GetStringAsync("url")
            .ConfigureAwait(false);  // Ne capture pas le contexte
        return data;
    }

    // ✅ Solution 3 : Task.Run pour isoler (si vraiment nécessaire)
    public string GetDataWithTaskRun()
    {
        // S'exécute sur un thread pool sans SynchronizationContext
        return Task.Run(async () => await _service.FetchAsync()).Result;
    }

    // Pattern pour cancellation propre
    public async Task<string> FetchWithCancellation(CancellationToken cancellationToken)
    {
        using var client = new HttpClient();

        try
        {
            var response = await client.GetStringAsync("url", cancellationToken);
            return response;
        }
        catch (OperationCanceledException)
        {
            // Gérer l'annulation gracieusement
            return string.Empty;
        }
    }

    // Timeout pattern
    public async Task<string> FetchWithTimeout(TimeSpan timeout)
    {
        using var cts = new CancellationTokenSource(timeout);

        try
        {
            return await FetchWithCancellation(cts.Token);
        }
        catch (OperationCanceledException)
        {
            throw new TimeoutException("La requête a expiré");
        }
    }

    private static readonly HttpClient HttpClient = new();
}

public interface IDataService
{
    Task<string> FetchAsync();
}

La règle d'or : "async all the way". Éviter de mélanger code synchrone et asynchrone. Dans ASP.NET Core, le SynchronizationContext n'existe pas, réduisant les risques de deadlock.

Prêt à réussir tes entretiens .NET ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Dependency Injection et Architecture

Question 8 : Expliquez les différentes durées de vie en DI (Scoped, Transient, Singleton)

La compréhension des lifetimes est essentielle pour éviter les bugs de concurrence et les fuites mémoire.

DependencyInjectionLifetimes.cscsharp
// Les trois durées de vie et leurs implications

// SINGLETON : une seule instance pour toute l'application
public class SingletonService
{
    private readonly Guid _id = Guid.NewGuid();
    public Guid Id => _id;

    // ⚠️ DANGER : pas de state mutable sans synchronisation
    // ❌ private int _counter; // Race conditions possibles
}

// SCOPED : une instance par requête HTTP (ou scope)
public class ScopedService
{
    private readonly Guid _id = Guid.NewGuid();
    public Guid Id => _id;

    // ✅ Safe : chaque requête a sa propre instance
    // Idéal pour DbContext, UnitOfWork
}

// TRANSIENT : nouvelle instance à chaque injection
public class TransientService
{
    private readonly Guid _id = Guid.NewGuid();
    public Guid Id => _id;

    // Idéal pour les services légers et stateless
}

// Configuration dans Program.cs
public static class ServiceConfiguration
{
    public static void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<SingletonService>();
        services.AddScoped<ScopedService>();
        services.AddTransient<TransientService>();

        // Entity Framework : TOUJOURS Scoped
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(connectionString));

        // HttpClient : utiliser IHttpClientFactory
        services.AddHttpClient<IApiClient, ApiClient>();
    }
}

// ❌ CAPTIVE DEPENDENCY : Singleton qui dépend d'un Scoped
public class BadSingletonService
{
    // ⚠️ Le ScopedService sera capturé et réutilisé indéfiniment
    // Cause des bugs de concurrence et des données périmées
    private readonly ScopedService _scoped;

    public BadSingletonService(ScopedService scoped)
    {
        _scoped = scoped;
    }
}

// ✅ Solution : utiliser IServiceScopeFactory
public class GoodSingletonService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public GoodSingletonService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task DoWork()
    {
        // Créer un scope explicite pour obtenir un ScopedService frais
        using var scope = _scopeFactory.CreateScope();
        var scoped = scope.ServiceProvider.GetRequiredService<ScopedService>();
        // Utiliser scoped...
    }
}

Règle : un service ne doit jamais dépendre d'un service avec une durée de vie plus courte. Singleton → Scoped → Transient.

Question 9 : Quels sont les principaux design patterns en .NET ?

Les recruteurs attendent une connaissance pratique des patterns, pas juste leur définition.

DesignPatterns.cscsharp
// Patterns courants en C#/.NET

// REPOSITORY : abstraction de l'accès aux données
public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id);
    Task<IEnumerable<User>> GetAllAsync();
    Task AddAsync(User user);
    Task UpdateAsync(User user);
    Task DeleteAsync(int id);
}

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

    public UserRepository(AppDbContext context) => _context = context;

    public async Task<User?> GetByIdAsync(int id)
        => await _context.Users.FindAsync(id);

    public async Task<IEnumerable<User>> GetAllAsync()
        => await _context.Users.ToListAsync();

    public async Task AddAsync(User user)
        => await _context.Users.AddAsync(user);

    public async Task UpdateAsync(User user)
        => _context.Users.Update(user);

    public async Task DeleteAsync(int id)
    {
        var user = await GetByIdAsync(id);
        if (user != null) _context.Users.Remove(user);
    }
}

// UNIT OF WORK : coordination des transactions
public interface IUnitOfWork : IDisposable
{
    IUserRepository Users { get; }
    IOrderRepository Orders { get; }
    Task<int> SaveChangesAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
        Users = new UserRepository(context);
        Orders = new OrderRepository(context);
    }

    public IUserRepository Users { get; }
    public IOrderRepository Orders { get; }

    public async Task<int> SaveChangesAsync()
        => await _context.SaveChangesAsync();

    public void Dispose() => _context.Dispose();
}

// FACTORY : création d'objets complexes
public interface INotificationFactory
{
    INotification Create(NotificationType type);
}

public class NotificationFactory : INotificationFactory
{
    public INotification Create(NotificationType type) => type switch
    {
        NotificationType.Email => new EmailNotification(),
        NotificationType.Sms => new SmsNotification(),
        NotificationType.Push => new PushNotification(),
        _ => throw new ArgumentException($"Type inconnu: {type}")
    };
}

// DECORATOR : ajouter des comportements dynamiquement
public interface IUserService
{
    Task<User> GetUserAsync(int id);
}

public class UserService : IUserService
{
    private readonly IUserRepository _repository;
    public UserService(IUserRepository repository) => _repository = repository;

    public async Task<User> GetUserAsync(int id)
        => await _repository.GetByIdAsync(id)
           ?? throw new NotFoundException($"User {id} not found");
}

// Decorator qui ajoute du caching
public class CachedUserService : IUserService
{
    private readonly IUserService _inner;
    private readonly IMemoryCache _cache;

    public CachedUserService(IUserService inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<User> GetUserAsync(int id)
    {
        var cacheKey = $"user:{id}";

        if (_cache.TryGetValue(cacheKey, out User? cached))
            return cached!;

        var user = await _inner.GetUserAsync(id);
        _cache.Set(cacheKey, user, TimeSpan.FromMinutes(5));

        return user;
    }
}

Ces patterns sont utilisés quotidiennement dans les applications .NET professionnelles. Le pattern Repository avec Unit of Work est particulièrement courant avec Entity Framework.

Entity Framework Core

Question 10 : Comment optimiser les performances avec EF Core ?

EF Core peut être très performant ou très lent selon son utilisation. Cette question évalue la connaissance des bonnes pratiques.

EFCoreOptimization.cscsharp
// Techniques d'optimisation des requêtes

public class EFCorePerformance
{
    private readonly AppDbContext _context;

    // ❌ Problème N+1 : une requête par order
    public async Task<List<User>> GetUsersWithOrdersBad()
    {
        var users = await _context.Users.ToListAsync();

        foreach (var user in users)
        {
            // N requêtes supplémentaires !
            var orders = await _context.Orders
                .Where(o => o.UserId == user.Id)
                .ToListAsync();
        }

        return users;
    }

    // ✅ Eager Loading avec Include
    public async Task<List<User>> GetUsersWithOrdersGood()
    {
        return await _context.Users
            .Include(u => u.Orders)        // JOIN en SQL
            .ThenInclude(o => o.Products)  // Include imbriqué
            .ToListAsync();
    }

    // ✅ Projection pour charger uniquement les données nécessaires
    public async Task<List<UserDto>> GetUserSummaries()
    {
        return await _context.Users
            .Select(u => new UserDto
            {
                Id = u.Id,
                Name = u.Name,
                OrderCount = u.Orders.Count,  // Calculé côté SQL
                TotalSpent = u.Orders.Sum(o => o.Total)
            })
            .ToListAsync();
    }

    // ✅ Split Query pour les grandes collections
    public async Task<List<User>> GetUsersWithSplitQuery()
    {
        return await _context.Users
            .Include(u => u.Orders)
            .AsSplitQuery()  // Génère des requêtes séparées au lieu d'un gros JOIN
            .ToListAsync();
    }

    // ✅ No Tracking pour les lectures seules
    public async Task<List<User>> GetUsersReadOnly()
    {
        return await _context.Users
            .AsNoTracking()  // Pas de change tracking = plus rapide
            .ToListAsync();
    }

    // ✅ Batch operations (EF Core 7+)
    public async Task DeleteInactiveUsers()
    {
        // Une seule requête DELETE au lieu de charger puis supprimer
        await _context.Users
            .Where(u => !u.IsActive && u.LastLoginAt < DateTime.UtcNow.AddYears(-1))
            .ExecuteDeleteAsync();
    }

    // ✅ Bulk update
    public async Task DeactivateOldUsers()
    {
        await _context.Users
            .Where(u => u.LastLoginAt < DateTime.UtcNow.AddMonths(-6))
            .ExecuteUpdateAsync(u => u.SetProperty(x => x.IsActive, false));
    }

    // ✅ Compiled Queries pour les requêtes fréquentes
    private static readonly Func<AppDbContext, int, Task<User?>> GetUserById =
        EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
            ctx.Users.FirstOrDefault(u => u.Id == id));

    public async Task<User?> GetUserOptimized(int id)
    {
        return await GetUserById(_context, id);
    }
}
Monitoring des requêtes

Activer le logging SQL en développement avec optionsBuilder.LogTo(Console.WriteLine) pour identifier les requêtes problématiques. En production, utiliser des outils comme MiniProfiler ou Application Insights.

Question 11 : Expliquez les migrations et la gestion du schéma

La gestion des migrations est critique pour les déploiements en production.

MigrationStrategies.cscsharp
// Gestion professionnelle des migrations EF Core

// Configuration du DbContext avec conventions
public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Appliquer toutes les configurations IEntityTypeConfiguration
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);

        // Convention globale pour les dates
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            foreach (var property in entityType.GetProperties())
            {
                if (property.ClrType == typeof(DateTime))
                {
                    property.SetColumnType("datetime2");
                }
            }
        }
    }
}

// Configuration fluide séparée
public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.ToTable("Users");

        builder.HasKey(u => u.Id);

        builder.Property(u => u.Email)
            .IsRequired()
            .HasMaxLength(256);

        builder.HasIndex(u => u.Email)
            .IsUnique();

        builder.HasMany(u => u.Orders)
            .WithOne(o => o.User)
            .HasForeignKey(o => o.UserId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

// Seeding de données
public class DataSeeder
{
    public static void Seed(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Role>().HasData(
            new Role { Id = 1, Name = "Admin" },
            new Role { Id = 2, Name = "User" }
        );
    }
}

Commandes de migration essentielles :

  • dotnet ef migrations add NomMigration - Créer une migration
  • dotnet ef database update - Appliquer les migrations
  • dotnet ef migrations script - Générer le script SQL
  • dotnet ef migrations remove - Supprimer la dernière migration

ASP.NET Core

Question 12 : Expliquez le pipeline de middleware ASP.NET Core

Le pipeline de middleware est le cœur d'ASP.NET Core. Comprendre son fonctionnement est essentiel.

MiddlewarePipeline.cscsharp
// Architecture du pipeline de requêtes

// Custom Middleware - classe complète
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // AVANT : exécuté à l'aller (request)
        var stopwatch = Stopwatch.StartNew();
        _logger.LogInformation("Request: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        try
        {
            // Passer au middleware suivant
            await _next(context);
        }
        finally
        {
            // APRÈS : exécuté au retour (response)
            stopwatch.Stop();
            _logger.LogInformation("Response: {StatusCode} in {ElapsedMs}ms",
                context.Response.StatusCode,
                stopwatch.ElapsedMilliseconds);
        }
    }
}

// Extension pour l'enregistrement
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}

// Configuration du pipeline dans Program.cs
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        // L'ORDRE est CRITIQUE !

        // 1. Gestion des exceptions (doit être en premier)
        app.UseExceptionHandler("/error");

        // 2. HTTPS Redirection
        app.UseHttpsRedirection();

        // 3. Fichiers statiques (court-circuite si trouvé)
        app.UseStaticFiles();

        // 4. Routing (détermine l'endpoint)
        app.UseRouting();

        // 5. CORS (doit être entre Routing et Auth)
        app.UseCors();

        // 6. Authentication (qui êtes-vous ?)
        app.UseAuthentication();

        // 7. Authorization (avez-vous le droit ?)
        app.UseAuthorization();

        // 8. Custom middleware
        app.UseRequestLogging();

        // 9. Endpoints (exécute le controller/action)
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
            endpoints.MapRazorPages();
        });
    }
}

// Middleware conditionnel
public static class ConditionalMiddleware
{
    public static IApplicationBuilder UseWhen(
        this IApplicationBuilder app,
        Func<HttpContext, bool> predicate,
        Action<IApplicationBuilder> configuration)
    {
        // Branche conditionnelle du pipeline
        return app.UseWhen(predicate, configuration);
    }

    public static void Example(IApplicationBuilder app)
    {
        // Appliquer un middleware seulement pour /api/*
        app.UseWhen(
            context => context.Request.Path.StartsWithSegments("/api"),
            apiApp => apiApp.UseMiddleware<ApiRateLimitingMiddleware>()
        );
    }
}

Le middleware s'exécute dans l'ordre d'enregistrement à l'aller (request) et dans l'ordre inverse au retour (response).

Question 13 : Comment implémenter l'authentification JWT ?

L'authentification JWT est le standard pour les APIs REST modernes.

JwtAuthentication.cscsharp
// Configuration complète de l'authentification JWT

public static class JwtConfiguration
{
    public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration config)
    {
        var jwtSettings = config.GetSection("Jwt").Get<JwtSettings>()!;

        services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = jwtSettings.Issuer,
                ValidAudience = jwtSettings.Audience,
                IssuerSigningKey = new SymmetricSecurityKey(
                    Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
                ClockSkew = TimeSpan.Zero  // Pas de tolérance sur l'expiration
            };

            // Événements pour logging/debugging
            options.Events = new JwtBearerEvents
            {
                OnAuthenticationFailed = context =>
                {
                    if (context.Exception is SecurityTokenExpiredException)
                    {
                        context.Response.Headers.Add("Token-Expired", "true");
                    }
                    return Task.CompletedTask;
                }
            };
        });
    }
}

public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpirationMinutes { get; set; } = 60;
}

// Service de génération de tokens
public class TokenService
{
    private readonly JwtSettings _settings;

    public TokenService(IOptions<JwtSettings> settings)
    {
        _settings = settings.Value;
    }

    public string GenerateToken(User user, IEnumerable<string> roles)
    {
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_settings.SecretKey));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new("name", user.Name)
        };

        // Ajouter les rôles comme claims
        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var token = new JwtSecurityToken(
            issuer: _settings.Issuer,
            audience: _settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_settings.ExpirationMinutes),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.UTF8.GetBytes(_settings.SecretKey);

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = true,
                ValidIssuer = _settings.Issuer,
                ValidateAudience = true,
                ValidAudience = _settings.Audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            }, out _);

            return principal;
        }
        catch
        {
            return null;
        }
    }
}

// Utilisation dans un controller
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly TokenService _tokenService;
    private readonly IUserService _userService;

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginDto dto)
    {
        var user = await _userService.ValidateCredentialsAsync(dto.Email, dto.Password);
        if (user == null)
            return Unauthorized(new { message = "Invalid credentials" });

        var roles = await _userService.GetRolesAsync(user.Id);
        var token = _tokenService.GenerateToken(user, roles);

        return Ok(new { token, expiresIn = 3600 });
    }

    [Authorize]  // Requiert un token valide
    [HttpGet("profile")]
    public IActionResult GetProfile()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        return Ok(new { userId });
    }

    [Authorize(Roles = "Admin")]  // Requiert le rôle Admin
    [HttpGet("admin")]
    public IActionResult AdminOnly()
    {
        return Ok(new { message = "Welcome, Admin!" });
    }
}

Prêt à réussir tes entretiens .NET ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Questions avancées

Question 14 : Qu'est-ce que Span<T> et Memory<T> ?

Ces types permettent de manipuler des zones de mémoire sans allocation, essentiels pour le code haute performance.

SpanAndMemory.cscsharp
// Types pour la manipulation mémoire performante

public class HighPerformanceDemo
{
    // Span`<T>` : vue sur une zone mémoire contiguë (stack only)
    public static void SpanBasics()
    {
        // Span sur un tableau
        int[] numbers = { 1, 2, 3, 4, 5 };
        Span<int> span = numbers.AsSpan();

        // Slice sans allocation
        Span<int> slice = span.Slice(1, 3);  // [2, 3, 4]

        // Modification affecte le tableau original
        slice[0] = 100;
        Console.WriteLine(numbers[1]); // 100

        // Span sur la stack (stackalloc)
        Span<int> stackSpan = stackalloc int[100];
        stackSpan.Fill(42);
    }

    // Parsing sans allocation avec Span
    public static bool TryParseDate(ReadOnlySpan<char> input, out DateTime date)
    {
        // Format: "2024-01-15"
        date = default;

        if (input.Length != 10) return false;

        // Slicing sans créer de nouvelles strings
        var yearSpan = input.Slice(0, 4);
        var monthSpan = input.Slice(5, 2);
        var daySpan = input.Slice(8, 2);

        if (!int.TryParse(yearSpan, out int year)) return false;
        if (!int.TryParse(monthSpan, out int month)) return false;
        if (!int.TryParse(daySpan, out int day)) return false;

        date = new DateTime(year, month, day);
        return true;
    }

    // Memory`<T>` : comme Span mais peut être stocké sur le heap
    public async Task<int> ProcessDataAsync(Memory<byte> buffer)
    {
        // Memory peut traverser les frontières async
        await Task.Delay(100);

        // Convertir en Span pour le traitement
        Span<byte> span = buffer.Span;
        int sum = 0;
        foreach (var b in span)
        {
            sum += b;
        }

        return sum;
    }

    // ArrayPool : réutilisation de tableaux pour éviter les allocations
    public static void UseArrayPool()
    {
        // Louer un tableau du pool
        byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);

        try
        {
            // Utiliser le buffer...
            // Note : peut être plus grand que demandé
            Console.WriteLine($"Buffer size: {buffer.Length}");
        }
        finally
        {
            // TOUJOURS retourner au pool
            ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
        }
    }

    // Benchmark comparatif
    public static string SubstringTraditional(string input, int start, int length)
    {
        // Crée une nouvelle string = allocation
        return input.Substring(start, length);
    }

    public static ReadOnlySpan<char> SubstringWithSpan(ReadOnlySpan<char> input, int start, int length)
    {
        // Retourne une vue = AUCUNE allocation
        return input.Slice(start, length);
    }
}

Span<T> est idéal pour le traitement de chaînes, la parsing, et les opérations sur les tableaux sans allocation.

Question 15 : Expliquez les records et leurs cas d'usage

Les records (C# 9+) sont un type de référence immuable avec égalité par valeur, parfaits pour les DTOs et les value objects.

RecordsDemo.cscsharp
// Fonctionnalités et cas d'usage des records

// Record class (référence, immuable par défaut)
public record Person(string FirstName, string LastName, DateOnly BirthDate)
{
    // Propriété calculée
    public int Age => DateTime.Today.Year - BirthDate.Year;

    // Méthode additionnelle
    public string FullName => $"{FirstName} {LastName}";
}

// Record avec validation
public record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (!IsValidEmail(value))
            throw new ArgumentException("Invalid email format");
        Value = value;
    }

    private static bool IsValidEmail(string email)
        => !string.IsNullOrEmpty(email) && email.Contains('@');
}

// Record struct (valeur, C# 10+)
public readonly record struct Point(double X, double Y)
{
    public double Distance => Math.Sqrt(X * X + Y * Y);
}

public class RecordUsageDemo
{
    public void DemonstrateFeatures()
    {
        // Création
        var person1 = new Person("John", "Doe", new DateOnly(1990, 5, 15));

        // Égalité par valeur (pas par référence)
        var person2 = new Person("John", "Doe", new DateOnly(1990, 5, 15));
        Console.WriteLine(person1 == person2); // True

        // Mutation avec 'with' (crée une copie)
        var person3 = person1 with { LastName = "Smith" };
        Console.WriteLine(person1.LastName); // "Doe" (inchangé)
        Console.WriteLine(person3.LastName); // "Smith"

        // Deconstruction
        var (firstName, lastName, _) = person1;
        Console.WriteLine($"{firstName} {lastName}");

        // ToString() auto-généré
        Console.WriteLine(person1);
        // Output: Person { FirstName = John, LastName = Doe, BirthDate = 15/05/1990 }
    }

    // Records comme DTOs (transfert de données)
    public record CreateUserRequest(string Email, string Password, string Name);
    public record UserResponse(int Id, string Email, string Name, DateTime CreatedAt);

    // Records comme Value Objects (DDD)
    public record Money(decimal Amount, string Currency)
    {
        public static Money operator +(Money a, Money b)
        {
            if (a.Currency != b.Currency)
                throw new InvalidOperationException("Currency mismatch");
            return new Money(a.Amount + b.Amount, a.Currency);
        }
    }

    // Record avec héritage
    public abstract record Shape(string Color);
    public record Circle(string Color, double Radius) : Shape(Color);
    public record Rectangle(string Color, double Width, double Height) : Shape(Color);
}

Les records sont idéaux pour : DTOs, Value Objects, configurations immuables, et tout objet où l'identité est basée sur les valeurs plutôt que la référence.

Question 16 : Comment implémenter un système de cache distribué ?

Le caching est essentiel pour les performances des applications à grande échelle.

DistributedCaching.cscsharp
// Implémentation de cache avec Redis

public interface ICacheService
{
    Task<T?> GetAsync<T>(string key);
    Task SetAsync<T>(string key, T value, TimeSpan? expiration = null);
    Task RemoveAsync(string key);
    Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null);
}

public class RedisCacheService : ICacheService
{
    private readonly IDistributedCache _cache;
    private readonly JsonSerializerOptions _jsonOptions;

    public RedisCacheService(IDistributedCache cache)
    {
        _cache = cache;
        _jsonOptions = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        };
    }

    public async Task<T?> GetAsync<T>(string key)
    {
        var data = await _cache.GetStringAsync(key);

        if (string.IsNullOrEmpty(data))
            return default;

        return JsonSerializer.Deserialize<T>(data, _jsonOptions);
    }

    public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null)
    {
        var options = new DistributedCacheEntryOptions();

        if (expiration.HasValue)
        {
            options.AbsoluteExpirationRelativeToNow = expiration;
        }
        else
        {
            options.SlidingExpiration = TimeSpan.FromMinutes(10);
        }

        var json = JsonSerializer.Serialize(value, _jsonOptions);
        await _cache.SetStringAsync(key, json, options);
    }

    public async Task RemoveAsync(string key)
    {
        await _cache.RemoveAsync(key);
    }

    // Pattern Cache-Aside avec factory
    public async Task<T> GetOrSetAsync<T>(
        string key,
        Func<Task<T>> factory,
        TimeSpan? expiration = null)
    {
        var cached = await GetAsync<T>(key);

        if (cached != null)
            return cached;

        var value = await factory();
        await SetAsync(key, value, expiration);

        return value;
    }
}

// Utilisation dans un service
public class ProductService
{
    private readonly ICacheService _cache;
    private readonly IProductRepository _repository;

    public ProductService(ICacheService cache, IProductRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        var cacheKey = $"product:{id}";

        return await _cache.GetOrSetAsync(
            cacheKey,
            async () => await _repository.GetByIdAsync(id),
            TimeSpan.FromMinutes(30)
        );
    }

    // Cache invalidation
    public async Task UpdateProductAsync(int id, UpdateProductDto dto)
    {
        await _repository.UpdateAsync(id, dto);

        // Invalider le cache
        await _cache.RemoveAsync($"product:{id}");
    }
}

// Configuration dans Program.cs
public static class CacheConfiguration
{
    public static void AddCaching(this IServiceCollection services, IConfiguration config)
    {
        services.AddStackExchangeRedisCache(options =>
        {
            options.Configuration = config.GetConnectionString("Redis");
            options.InstanceName = "MyApp:";
        });

        services.AddSingleton<ICacheService, RedisCacheService>();
    }
}
Cache Invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things." Définir une stratégie claire d'invalidation du cache est essentiel pour éviter les données périmées.

Question 17 : Comment gérer les transactions distribuées ?

Dans les architectures microservices, les transactions distribuées nécessitent des patterns spécifiques.

DistributedTransactions.cscsharp
// Patterns pour la cohérence dans les systèmes distribués

// Pattern SAGA avec Orchestration
public class OrderSaga
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;
    private readonly INotificationService _notificationService;

    public async Task<OrderResult> ProcessOrderAsync(CreateOrderCommand command)
    {
        Order? order = null;
        PaymentResult? payment = null;
        InventoryReservation? reservation = null;

        try
        {
            // Étape 1 : Créer la commande
            order = await _orderRepository.CreateAsync(command);

            // Étape 2 : Réserver l'inventaire
            reservation = await _inventoryService.ReserveAsync(order.Items);

            // Étape 3 : Traiter le paiement
            payment = await _paymentService.ProcessAsync(order.Total, command.PaymentMethod);

            // Étape 4 : Confirmer la commande
            await _orderRepository.ConfirmAsync(order.Id);

            // Étape 5 : Notification (non-critique)
            await _notificationService.SendOrderConfirmationAsync(order);

            return OrderResult.Success(order.Id);
        }
        catch (Exception ex)
        {
            // COMPENSATION : annuler les étapes précédentes dans l'ordre inverse

            if (payment?.IsSuccessful == true)
            {
                await _paymentService.RefundAsync(payment.TransactionId);
            }

            if (reservation != null)
            {
                await _inventoryService.ReleaseReservationAsync(reservation.Id);
            }

            if (order != null)
            {
                await _orderRepository.CancelAsync(order.Id, ex.Message);
            }

            return OrderResult.Failure(ex.Message);
        }
    }
}

// Pattern Outbox pour la publication fiable d'événements
public class OutboxProcessor
{
    private readonly AppDbContext _context;
    private readonly IMessageBus _messageBus;

    public async Task ProcessOutboxAsync()
    {
        var pendingMessages = await _context.OutboxMessages
            .Where(m => m.ProcessedAt == null)
            .OrderBy(m => m.CreatedAt)
            .Take(100)
            .ToListAsync();

        foreach (var message in pendingMessages)
        {
            try
            {
                // Publier le message
                await _messageBus.PublishAsync(message.Type, message.Payload);

                // Marquer comme traité
                message.ProcessedAt = DateTime.UtcNow;
                await _context.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                message.RetryCount++;
                message.Error = ex.Message;
                await _context.SaveChangesAsync();
            }
        }
    }
}

// Modèle Outbox
public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = string.Empty;
    public string Payload { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? ProcessedAt { get; set; }
    public int RetryCount { get; set; }
    public string? Error { get; set; }
}

// Extension pour ajouter un message à l'outbox dans une transaction
public static class DbContextExtensions
{
    public static void AddOutboxMessage<T>(this AppDbContext context, T @event)
    {
        var message = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = typeof(T).Name,
            Payload = JsonSerializer.Serialize(@event),
            CreatedAt = DateTime.UtcNow
        };

        context.OutboxMessages.Add(message);
    }
}

Le pattern SAGA garantit la cohérence éventuelle dans les systèmes distribués. Le pattern Outbox assure la publication fiable des événements même en cas de panne.

Conclusion

Les entretiens C# et .NET évaluent une combinaison de connaissances théoriques sur le runtime et le langage, et de compétences pratiques en architecture et développement d'applications. Maîtriser les concepts fondamentaux tout en comprenant les patterns avancés distingue les développeurs seniors.

Checklist de préparation

  • ✅ Comprendre la différence entre value types et reference types
  • ✅ Maîtriser async/await et éviter les deadlocks
  • ✅ Connaître les différences entre IEnumerable et IQueryable
  • ✅ Optimiser les requêtes Entity Framework Core
  • ✅ Implémenter correctement le pattern IDisposable
  • ✅ Configurer l'injection de dépendances avec les bons lifetimes
  • ✅ Sécuriser les APIs avec JWT
  • ✅ Utiliser Span<T> et Memory<T> pour le code haute performance

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

La préparation doit combiner théorie et pratique. Construire des projets personnels, contribuer à l'écosystème open source .NET, et résoudre des exercices sur des plateformes comme HackerRank ou LeetCode consolide ces connaissances pour les entretiens les plus exigeants.

Tags

#csharp
#dotnet
#interview
#aspnet core
#entretien technique

Partager

Articles similaires