385 lines
18 KiB
Markdown
385 lines
18 KiB
Markdown
|
|
# csharp.md — Idiomes et conventions C#
|
||
|
|
|
||
|
|
Ce fichier rassemble mes pratiques C# transverses, indépendantes de Unity (qui a son propre fichier [`unity.md`](unity.md)). Il couvre aussi ce qui est spécifique à .NET : serialisation MessagePack, async/await, organisation de solution.
|
||
|
|
|
||
|
|
## Version et configuration
|
||
|
|
|
||
|
|
- **C# 9 minimum** sur tous mes projets actuels, .NET 6+ côté serveur, netstandard 2.1 pour la couche partagée (compatibilité Unity Mono).
|
||
|
|
- **Nullable reference types activés partout.** C'est non négociable :
|
||
|
|
- Côté .NET (serveur, tests, shared hors Unity) via le `.csproj` :
|
||
|
|
```xml
|
||
|
|
<PropertyGroup>
|
||
|
|
<Nullable>enable</Nullable>
|
||
|
|
</PropertyGroup>
|
||
|
|
```
|
||
|
|
- Côté Unity, via un `csc.rsp` à côté de chaque `.asmdef` :
|
||
|
|
```
|
||
|
|
-nullable
|
||
|
|
```
|
||
|
|
- Pour les dossiers qui intègrent du code tiers non nullable-compliant, un `Directory.Build.props` local désactive proprement :
|
||
|
|
```xml
|
||
|
|
<Project>
|
||
|
|
<PropertyGroup>
|
||
|
|
<Nullable>disabled</Nullable>
|
||
|
|
</PropertyGroup>
|
||
|
|
</Project>
|
||
|
|
```
|
||
|
|
- Pour les fichiers isolés : `#nullable disable` en première ligne.
|
||
|
|
|
||
|
|
- **Solution multi-projets** : chaque concern a son projet (`Server/`, `Shared/`, `Tests/`, `Client Unity`). La couche partagée est compilée deux fois (une par Unity Mono, une par .NET Core) pour garantir la compatibilité.
|
||
|
|
|
||
|
|
## Conventions de nommage
|
||
|
|
|
||
|
|
Les règles sont strictes et visuelles pour qu'on lise la visibilité d'un coup d'œil.
|
||
|
|
|
||
|
|
- **Publique** : `CamelCased` sans underscore. Ça vaut pour les classes, méthodes, propriétés, et champs publics.
|
||
|
|
- **Privé** : `_camelCased` avec underscore en préfixe. Signal fort : *ne pas toucher de l'extérieur, interne à la classe*.
|
||
|
|
- **Interfaces** : préfixe `I` (`IGameEvent`, `INetworkMessage`, `IUpdatable<T>`).
|
||
|
|
- **Génériques** : `T`, `TKey`, `TValue`, `TSelf` — clarté plutôt qu'originalité.
|
||
|
|
- **Constantes** : `PascalCase` aussi, pas de `SCREAMING_SNAKE`.
|
||
|
|
- **Booléens** : formes `IsXxx`, `HasXxx`, `CanXxx`, `ShouldXxx`. Jamais de double négation.
|
||
|
|
- **Events (au sens `.NET`)** : `OnXxx` pour les callbacks déclenchés.
|
||
|
|
|
||
|
|
Exemple typique :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public abstract class ConnectedBehaviour : MonoBehaviour {
|
||
|
|
protected GameManager GameManager { get; /* ... */ }
|
||
|
|
private GameManager? _gameManager = null;
|
||
|
|
private bool _isSetup;
|
||
|
|
internal CancellationTokenSource? _resetSource;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Le contraste visuel entre `GameManager` (propriété publique) et `_gameManager` (champ backing privé) rend la lecture sans ambiguïté.
|
||
|
|
|
||
|
|
## Nullable : comment je m'en sers
|
||
|
|
|
||
|
|
Le nullable est un système d'intentions. Je m'en sers pour communiquer avec le futur lecteur (moi compris) :
|
||
|
|
|
||
|
|
- **`T?`** : cette référence peut légitimement être nulle, tu dois gérer le cas.
|
||
|
|
- **`T`** : cette référence ne devrait jamais être nulle, si elle l'est, c'est un bug.
|
||
|
|
- **`null!`** : je te promets que cette valeur sera initialisée avant usage (typiquement par l'inspecteur Unity ou un framework DI).
|
||
|
|
- **`x!`** : j'assume, à cet endroit précis, que `x` n'est pas null — par exemple après un `await UniTask.WaitUntil(() => x != null, ...)`.
|
||
|
|
- **`#nullable disable`** : uniquement pour les fichiers tiers ou legacy incompatibles.
|
||
|
|
|
||
|
|
Je ne mets **jamais** un `!` pour faire taire un warning sans raison valable. Chaque `!` est un contrat : si plus tard il se révèle faux, c'est moi qui aurai menti au compilateur.
|
||
|
|
|
||
|
|
Pattern classique pour les références Unity injectées :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
[Required]
|
||
|
|
public TextMeshProUGUI StageText = null!;
|
||
|
|
```
|
||
|
|
|
||
|
|
`null!` parce que l'attribut `[Required]` (Odin/Artificetoolkit) + la validation éditeur garantissent que le champ est affecté avant l'exécution. C'est une promesse documentée, pas un contournement.
|
||
|
|
|
||
|
|
## Interfaces et abstractions
|
||
|
|
|
||
|
|
Une interface existe pour une raison précise. Je distingue :
|
||
|
|
|
||
|
|
- **Interfaces de contrat** — pour décrire ce qu'un type sait faire, utilisées pour le polymorphisme :
|
||
|
|
```csharp
|
||
|
|
public interface IGameEvent {
|
||
|
|
Guid GetId();
|
||
|
|
void Apply(GameState gameState, EventBus? sideEffectManager);
|
||
|
|
List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- **Interfaces de testabilité / inversion** — pour pouvoir injecter un mock. Mais seulement si le besoin existe ; pas par principe.
|
||
|
|
- **Interfaces marker + Union** — pour le versioning via MessagePack (voir plus bas).
|
||
|
|
|
||
|
|
**Ce que je refuse** : l'interface d'une seule implémentation créée "par précaution". Si je trouve `IThing` avec uniquement `Thing`, je supprime l'interface. Le refactor inverse est facile, l'inverse pas.
|
||
|
|
|
||
|
|
## Génériques et contraintes
|
||
|
|
|
||
|
|
J'utilise les génériques largement quand ils apportent de la clarté et du type-safety :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
|
||
|
|
public T? Data { get; private set; }
|
||
|
|
public void Set(T data) { ... }
|
||
|
|
protected abstract void OnSet(GameState state, T element);
|
||
|
|
}
|
||
|
|
|
||
|
|
public interface IUpdatable<in T> {
|
||
|
|
void UpdateFrom(T source);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void BroadcastBut<T>(Func<T, bool> shouldSkip, T message)
|
||
|
|
where T : INetworkMessage { ... }
|
||
|
|
```
|
||
|
|
|
||
|
|
Contraintes : `where T : INetworkMessage`, `where T : class`, `where T : struct, new()`. Je déclare les contraintes les plus fortes possibles — plus le compilateur en sait, moins on a de bugs.
|
||
|
|
|
||
|
|
## Immutabilité
|
||
|
|
|
||
|
|
Immutable par défaut quand c'est raisonnable :
|
||
|
|
|
||
|
|
- `readonly` sur les champs instanciés une fois en constructor.
|
||
|
|
- `init` sur les propriétés (C# 9+) quand on veut permettre l'initialisation par objet-initializer sans setter public.
|
||
|
|
- `record` pour les value objects qui méritent equality par valeur.
|
||
|
|
- `struct` pour les petites données purement valeur (coordonnées, timings, IDs).
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public struct BeatTiming {
|
||
|
|
public readonly int Beat;
|
||
|
|
public readonly float Offset;
|
||
|
|
public BeatTiming(int beat, float offset) { Beat = beat; Offset = offset; }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Attention : une collection `readonly` n'empêche pas ses éléments de muter, elle empêche juste le conteneur d'être réaffecté. Quand c'est important, je passe par `IReadOnlyList<T>` en exposition publique.
|
||
|
|
|
||
|
|
## Async / await
|
||
|
|
|
||
|
|
Règles stables :
|
||
|
|
|
||
|
|
- **UniTask** côté Unity et côté partagé qui tourne en Unity (zéro-allocation, PlayerLoop-aware).
|
||
|
|
- **Task** côté serveur .NET pur.
|
||
|
|
- **Toujours passer un `CancellationToken`** quand la tâche peut survivre à un lifecycle (GameObject, connexion, requête HTTP). Pas par pureté, par pragmatisme : les tâches orphelines causent des leaks.
|
||
|
|
- **`Forget()`** pour les fire-and-forget UniTask explicites (typiquement un `Setup()` appelé depuis `OnEnable`). Je ne laisse pas un `async void` traîner.
|
||
|
|
- **`SuppressCancellationThrow()`** quand je ne veux pas qu'une cancellation remonte en exception.
|
||
|
|
- **Pas de `.Result` ni `.Wait()`** — jamais. Deadlock garanti un jour.
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
protected virtual void OnEnable() {
|
||
|
|
Setup().Forget();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async UniTask Setup() {
|
||
|
|
if (!_isSetup) {
|
||
|
|
if (GameManager == null) {
|
||
|
|
await UniTask.WaitUntil(
|
||
|
|
() => GameManager != null,
|
||
|
|
PlayerLoopTiming.Update,
|
||
|
|
gameObject.GetCancellationTokenOnDestroy());
|
||
|
|
}
|
||
|
|
_isSetup = true;
|
||
|
|
OnSetup(GameManager!.State);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Le pattern "utility async throttled" que je réutilise souvent :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public static Action CreateThrottled(Action action, UniTask delayTask) {
|
||
|
|
bool throttling = false;
|
||
|
|
async void ThrottledEventHandler() {
|
||
|
|
if (throttling) return;
|
||
|
|
action();
|
||
|
|
throttling = true;
|
||
|
|
await delayTask.SuppressCancellationThrow();
|
||
|
|
throttling = false;
|
||
|
|
}
|
||
|
|
return ThrottledEventHandler;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Sérialisation : MessagePack + Union
|
||
|
|
|
||
|
|
Pour tout ce qui doit persister ou transiter (saves, réseau), j'utilise **MessagePack for C#** :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
[MessagePackObject]
|
||
|
|
public class CharacterMoveGameEvent : GameEvent {
|
||
|
|
[Key(0)] public int Id;
|
||
|
|
[Key(1)] public ushort CharacterShortId;
|
||
|
|
[Key(2)] public Vector3 Position;
|
||
|
|
|
||
|
|
[SerializationConstructor]
|
||
|
|
public CharacterMoveGameEvent(int id, ushort characterShortId, Vector3 position) {
|
||
|
|
Id = id;
|
||
|
|
CharacterShortId = characterShortId;
|
||
|
|
Position = position;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Notes importantes :
|
||
|
|
|
||
|
|
- **`[MessagePackObject(true)]`** pour un mapping automatique par nom, **`[MessagePackObject]` + `[Key(N)]`** explicite pour les types qui vont vraiment en réseau ou en save (contrôle de schema, stabilité).
|
||
|
|
- **`[SerializationConstructor]`** pour lever l'ambiguïté quand plusieurs constructeurs existent.
|
||
|
|
- **AOT** : côté Unity IL2CPP, il faut générer les formatters via `mpc` :
|
||
|
|
```bash
|
||
|
|
mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
|
||
|
|
```
|
||
|
|
À refaire systématiquement après ajout d'un nouveau type serialisé.
|
||
|
|
- **Versioning par Union** : jamais de breaking change silencieux sur un format sérialisé.
|
||
|
|
```csharp
|
||
|
|
[Union(0, typeof(GameState))]
|
||
|
|
[Union(1, typeof(GameStateV2))]
|
||
|
|
public interface IGameState {
|
||
|
|
byte[] Serialize();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
Si un champ doit changer de type ou de nom, je crée un `V2` et je laisse le `V0/V1` chargeable. Les saves ne se cassent pas, le serveur peut accepter plusieurs versions simultanément.
|
||
|
|
|
||
|
|
## LINQ et collections
|
||
|
|
|
||
|
|
J'utilise LINQ largement pour l'expressivité déclarative :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
var queue = new Queue<RuneSlotState>(
|
||
|
|
Edges.Keys.Where(node => inDegree[node] == 0)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
Règles :
|
||
|
|
|
||
|
|
- LINQ est bon pour la lisibilité, pas pour la performance hot-path. Dans une boucle de tick à 120 BPM, je profile et j'inline si besoin.
|
||
|
|
- `.ToList()` explicite quand je matérialise — une énumération différée consommée deux fois, c'est deux exécutions.
|
||
|
|
- Dans les hot-paths, ZLinq ou itérations manuelles (`for` plutôt que `foreach`) pour éviter les allocations.
|
||
|
|
|
||
|
|
Collections préférées :
|
||
|
|
|
||
|
|
- `List<T>` pour les séquences mutables ordonnées.
|
||
|
|
- `Dictionary<TKey, TValue>` pour les lookups.
|
||
|
|
- `ConcurrentDictionary<TKey, TValue>` et `ConcurrentQueue<T>` pour les échanges inter-thread (typiquement serveur ↔ réseau).
|
||
|
|
- `IReadOnlyList<T>` / `IReadOnlyDictionary<TKey, TValue>` en exposition publique quand je veux empêcher la mutation externe.
|
||
|
|
- `HashSet<T>` pour les appartenances rapides.
|
||
|
|
|
||
|
|
## Gestion d'erreurs
|
||
|
|
|
||
|
|
- **`Exception` pour les vraies erreurs** — état corrompu, invariant violé, input invalide côté API publique. Je préfère faire péter tôt et bruyamment.
|
||
|
|
- **Exceptions custom typées** quand je veux permettre un catch spécifique : `ApplicationException`, types dédiés au domaine.
|
||
|
|
- **Pas d'exceptions pour le contrôle de flux** — un cas "normal mais alternatif" est un retour explicite (`bool TryXxx(out T result)`, `T?`, tuple `(bool ok, T value)`, ou un type Result dédié si le projet l'introduit).
|
||
|
|
- **Assertions en éditeur** : `AssertApplicationConditions(gameState)` que je n'appelle qu'en `#if UNITY_EDITOR` pour ne pas payer le coût en prod.
|
||
|
|
- **Log d'erreur = message actionnable** : contexte, valeurs attendues vs observées, pas juste `"error"`.
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
|
||
|
|
lock (_lockObject) {
|
||
|
|
if (_isApplyingEvent)
|
||
|
|
throw new ApplicationException(
|
||
|
|
"Nested ApplyEvent detected — events must not be applied from within another event's Apply.");
|
||
|
|
_isApplyingEvent = true;
|
||
|
|
try { apply(this, eventBus); OnEventApplied(eventBus); }
|
||
|
|
finally { _isApplyingEvent = false; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Commentaires et documentation
|
||
|
|
|
||
|
|
- **XMLdoc sur les APIs publiques** (`///`). Paramètres, valeur de retour, remarques pertinentes. Pas besoin de tout documenter si le nom suffit, mais dès qu'il y a une subtilité d'usage, je documente.
|
||
|
|
- **Commentaires en prose** pour les logiques métier complexes — un paragraphe au-dessus de la méthode ou de la classe explique le *pourquoi*.
|
||
|
|
- **Pas de paraphrase** : un commentaire qui dit "incremente i" au-dessus de `i++` est du bruit. Le commentaire sert à dire ce que le code ne peut pas dire.
|
||
|
|
- **HTML dans les XMLdoc** pour les descriptions riches (listes, paragraphes) destinées à l'outillage ou aux tooltips in-game :
|
||
|
|
```csharp
|
||
|
|
/// <summary>
|
||
|
|
/// <b>Arcane de feu</b>
|
||
|
|
/// <para>Mécanique de flammes, basée sur l'utilisation de stacks...</para>
|
||
|
|
/// </summary>
|
||
|
|
public static readonly ArcaneDef Fire = new("Fire");
|
||
|
|
```
|
||
|
|
- **`TODO`, `FIXME`, `HACK`** explicites et signés — et reliés à un ticket si possible.
|
||
|
|
|
||
|
|
## Organisation de code
|
||
|
|
|
||
|
|
- **Un fichier = un type public**, même nom. Les types imbriqués ou privés peuvent partager le fichier de leur parent.
|
||
|
|
- **`namespace` qui reflète la structure de dossier**, sans sur-ingéniérie. `Runemaker.Engine`, `Runemaker.Client`, `Shared.Net.Messages`.
|
||
|
|
- **`using` triés et minimaux** — l'IDE le fait, je laisse faire Rider avec ses `.DotSettings` versionnés.
|
||
|
|
- **Méthodes courtes** — quand une méthode dépasse ~40 lignes ou a plus de 2 niveaux d'indentation profonde, je me demande si elle doit être split.
|
||
|
|
- **Régions `#region`** uniquement pour grouper par thème dans les classes massives où ça aide vraiment (tests). Pas pour cacher du code qu'on devrait déplacer.
|
||
|
|
|
||
|
|
## Patterns C# modernes que j'utilise
|
||
|
|
|
||
|
|
- **Expression-bodied members** pour les one-liners : `public Guid GetId() => _id;`
|
||
|
|
- **Pattern matching** pour la lisibilité :
|
||
|
|
```csharp
|
||
|
|
var combatEntries = changelist.Where(e =>
|
||
|
|
e is CombatStepEntry or CombatTargetEntry or CombatActionEntry
|
||
|
|
).ToList();
|
||
|
|
```
|
||
|
|
- **`switch expression`** plutôt que des if-else en cascade.
|
||
|
|
- **`nameof()`** à la place des strings littéraux pour les messages d'erreur et le binding.
|
||
|
|
- **`is null` / `is not null`** plutôt que `== null` (évite les surcharges d'opérateur surprenantes).
|
||
|
|
- **Target-typed `new()`** (C# 9) quand le type est évident à gauche : `Dictionary<string, int> scores = new();`
|
||
|
|
- **`record` pour les DTOs et value objects** — equality par valeur gratuite, immutabilité par défaut.
|
||
|
|
|
||
|
|
## IDE et tooling
|
||
|
|
|
||
|
|
- **Rider** (JetBrains) comme IDE principal, versioné via les fichiers `.DotSettings` :
|
||
|
|
- `TheRuneMaker.sln.DotSettings` / `.csproj.DotSettings` à la racine du projet
|
||
|
|
- Ces fichiers encodent les conventions de format, de nommage, de style, les analyses désactivées, etc.
|
||
|
|
- **Les conventions d'équipe vivent dans ces fichiers**, pas dans une page Confluence oubliée.
|
||
|
|
- **Hot reload** côté serveur, **Edit-mode reload** + Unity Hot Reload côté client quand possible.
|
||
|
|
- **`dotnet` CLI** pour les tâches répétitives :
|
||
|
|
```bash
|
||
|
|
dotnet build
|
||
|
|
dotnet test
|
||
|
|
dotnet test --filter "FullyQualifiedName~CombatTests"
|
||
|
|
dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
|
||
|
|
dotnet ef migrations add InitialCreate
|
||
|
|
dotnet ef database update
|
||
|
|
```
|
||
|
|
|
||
|
|
## Tests et mocks : Moq côté Unity / C#
|
||
|
|
|
||
|
|
Quand je dois mocker une dépendance (par exemple un accès PlayerPrefs, un wrapper d'I/O, un service réseau), j'utilise **Moq** avec **Castle.Core**. Le setup côté Unity :
|
||
|
|
|
||
|
|
- DLLs dans `Plugins/` ou via NuGetForUnity : `Moq.dll`, `Castle.Core.dll`, `System.Diagnostics.EventLog.dll`.
|
||
|
|
- Toutes ces DLLs taguées **Editor-only** dans l'inspecteur — elles n'ont rien à faire dans un build runtime.
|
||
|
|
- Le `.asmdef` de tests référence ces DLLs via `precompiledReferences`.
|
||
|
|
|
||
|
|
Le pattern clé : je ne teste jamais directement contre l'API Unity (PlayerPrefs, Time, `Application.persistentDataPath`). Je passe par une interface abstraite injectée dans le constructeur :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
public interface IPlayerPrefs {
|
||
|
|
void SetString(string key, string value);
|
||
|
|
int GetInt(string key);
|
||
|
|
bool HasKey(string key);
|
||
|
|
void Save();
|
||
|
|
}
|
||
|
|
|
||
|
|
public class PersistantState {
|
||
|
|
private readonly IPlayerPrefs _playerPrefs;
|
||
|
|
public PersistantState(IPlayerPrefs playerPrefs) {
|
||
|
|
_playerPrefs = playerPrefs;
|
||
|
|
}
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Le test injecte un `Mock<IPlayerPrefs>` et **vérifie les interactions** (pas les side-effects) — c'est du behavioral testing :
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
[Test]
|
||
|
|
public void SetLevelScore_SavesToDisk() {
|
||
|
|
var mock = new Mock<IPlayerPrefs>();
|
||
|
|
var state = new PersistantState(mock.Object);
|
||
|
|
|
||
|
|
state.SetLevelScore("level1", 100);
|
||
|
|
|
||
|
|
mock.Verify(x => x.SetString(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||
|
|
mock.Verify(x => x.Save(), Times.Once);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Bénéfice : les tests tournent en `dotnet test` en quelques millisecondes, sans ouvrir Unity, sans PlayerPrefs réels, sans fichiers à nettoyer. L'article détaillé est sur [samuel-bouchet.fr/posts/2024-10-25-unity-test-mocks](https://samuel-bouchet.fr/posts/2024-10-25-unity-test-mocks/).
|
||
|
|
|
||
|
|
## EntityFramework (côté serveur)
|
||
|
|
|
||
|
|
Quand un serveur a une DB, je passe par EF Core avec migrations :
|
||
|
|
|
||
|
|
- Installation des tools : `dotnet tool install --global dotnet-ef`
|
||
|
|
- Créer une migration : `dotnet ef migrations add <MigrationName>`
|
||
|
|
- Appliquer : `dotnet ef database update`
|
||
|
|
- Reset local propre : `dotnet ef database drop && dotnet ef migrations add InitialCreate && dotnet ef database update`
|
||
|
|
|
||
|
|
Les migrations sont des fichiers versionnés dans le repo, pas des scripts manuels. Chaque changement de schéma = une migration nommée explicitement.
|
||
|
|
|
||
|
|
## Anti-patterns que je refuse
|
||
|
|
|
||
|
|
- `.Result` et `.Wait()` sur des `Task` — deadlock garanti un jour.
|
||
|
|
- `catch (Exception e) { }` — un catch silencieux est un bug qu'on cache.
|
||
|
|
- `!` en cascade pour étouffer les warnings nullables sans réfléchir.
|
||
|
|
- Les propriétés auto-implémentées quand un champ public suffit (verbosité gratuite).
|
||
|
|
- Les DTOs avec un setter public sur tout quand un constructor + init ferait l'affaire.
|
||
|
|
- Les méthodes statiques qui cachent un état global mutable.
|
||
|
|
- Les interfaces d'une seule implémentation créées "au cas où".
|
||
|
|
- Les `throw new NotImplementedException()` qu'on oublie de remplacer.
|
||
|
|
- Le `var` quand le type n'est pas évident à la lecture — dans ce cas je le tape explicitement.
|