Concepts
What you will learn: Help a teammate understand and change code without guessing or breaking existing behavior. We use clear names, methods with a coherent level of detail, and checks that help refactoring preserve results. Code quality here is about the cost of change, not line count.
Before you start: Be comfortable distinguishing a method from a class, passing a value to a method, and reading its result. Refactoring changes code structure while preserving observable behavior. If basic C# is familiar, the before-and-after examples will explain the rest.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#Naming and one level of abstraction
Code is read more often than it is written. A name such as Process forces readers to inspect its implementation; CalculateInvoiceTotal communicates intent. Include units where relevant, such as timeoutSeconds, and use consistent business terminology across layers.
// Fragment inside an invoice service; collaborators are injected.
public async Task IssueAsync(Invoice invoice, CancellationToken ct)
{
Validate(invoice); // (1)
invoice.CalculateTotal(); // (2)
await repository.SaveAsync(invoice, ct); // (3)
}
- (1) Validation has an explicit name; field-level checks belong in its implementation.
- (2) The invoice owns its calculation instead of exposing a loop inside orchestration.
- (3) Saving stays at the same abstraction level: use-case steps rather than mixed and business details.
Keep functions focused on a coherent responsibility, rather than a fixed line count. Extracting every statement can add without meaning. The Async suffix and cancellation parameter make the calling contract visible.
#Guard clauses and failing fast
Nested conditions hide the normal path. Reject invalid input early, before side effects. Expected business rejection should still be an explicit result rather than an infrastructure exception.
decimal Discount(Customer customer, decimal total)
{
if (total > 0)
{
if (customer.IsActive)
{
if (customer.IsPreferred) return total * 0.05m;
}
}
return 0;
}decimal Discount(Customer customer, decimal total)
{
if (total <= 0) return 0; // (1)
if (!customer.IsActive) return 0;
if (!customer.IsPreferred) return 0;
return total * PreferredDiscountRate; // (2)
}
const decimal PreferredDiscountRate = 0.05m;- (1) Cases that already returned zero now exit early; test boundaries so evaluation order preserves behavior.
- (2) The successful path is visible, and the constant gives the number meaning.
ArgumentNullException.ThrowIfNull. Failing fast does not require duplicating every defensive check in every method.#Magic numbers and useful comments
An unexplained number makes a rule hard to change. Extract a constant for a stable meaning and configuration for an operational setting. Useful comments explain a non-obvious reason or external constraint; paraphrasing the statement adds noise and drifts.
// Add 30 days.
var due = issuedAt.AddDays(30);const int InvoicePaymentTermDays = 30; // (1)
// Contract requires calendar days, including weekends.
var due = issuedAt.AddDays(InvoicePaymentTermDays); // (2)- (1) The name identifies what thirty means; if terms vary by contract, store the value on the contract.
- (2) The comment prevents an accidental change from calendar days to business days.
Keep zero and one inline when their meaning is evident. A constant named Thirty adds nothing. Delete commented-out code and rely on version control; use an when a decision needs substantial context.
#DRY, KISS and YAGNI without overengineering
- Two places calculate the same invoice tax rule. A rate change applied to only one produces inconsistency: duplicated knowledge worth extracting.
- Similar-looking tax and discount functions may change for different reasons. Combining them through many flags can make the design harder to understand.
- addresses duplicated knowledge, favors simplicity, and avoids speculative needs. Extract shared behavior when the rule is actually shared, not merely when lines look similar.
DRY gives each piece of knowledge one authoritative representation; it does not merge all similar-looking statements. KISS favors the simplest design that meets requirements. YAGNI avoids flexibility for hypothetical needs. Two similar functions may represent rules that change for different reasons.
public interface IShippingFee
{
decimal Calculate(decimal subtotal);
}
public sealed class Checkout(IShippingFee shipping) // (1)
{
public decimal Total(decimal subtotal) =>
subtotal + shipping.Calculate(subtotal); // (2)
}
- (1) Composition supplies a shipping policy without an inheritance hierarchy.
- (2) Checkout requests behavior without knowing each carrier's implementation.
Use an interface for real variation or a boundary that needs separation. A small method is enough for one simple policy with no separation requirement. Inheritance can model genuine substitutability, but using it only for reuse couples callers to base-class behavior.
#Tell-don't-ask and the Law of Demeter
When every caller inspects order state and decides how to mutate it, transition rules spread across the application. Let the object protect its . The Law of Demeter limits knowledge of collaborator internals; it is not a rule for counting dots in a pipeline.
if (order.Status == OrderStatus.Pending)
order.Status = OrderStatus.Cancelled;
var postalCode = order.Customer.Address.PostalCode;order.Cancel(); // (1)
var destination = order.ShippingDestination; // (2)
// Inside Order:
public void Cancel()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Order cannot be cancelled.");
Status = OrderStatus.Cancelled;
}- (1) The order protects the transition itself; its status should have a private setter.
- (2) A shipping snapshot belongs to the order, avoiding traversal to a customer's current address.
These are design fragments; an explicit result can replace the exception for expected rejection. need not become rich domain objects, and forwarding every property creates noise. Apply the principle when it reduces coupling or protects a business invariant.
#Code smells and their refactorings
A smell is a prompt to investigate, rather than proof of bad code. Choose a refactoring around the reason for change and establish tests first.
| Smell | Signal and refactoring |
|---|---|
| Long method | Mixed steps: Extract Method with intention-revealing names. |
| Large class | Unrelated responsibilities: Extract Class around cohesion. |
| Primitive obsession | Strings for everything: introduce validated value objects such as Money or Email. |
| Feature envy | A method mostly uses another object's data: Move Method to that object. |
| Shotgun surgery | One rule requires edits in many files: consolidate it with Move Method and Move Field. |
| Divergent change | One class changes for unrelated reasons: Extract Class to separate responsibilities. |
| Data clumps | The same parameters travel together: Introduce Parameter Object. |
| Switch statements | Repeated type switches: Replace Conditional with Polymorphism or a strategy. |
| Speculative generality | Unused hooks and abstractions: Inline Class, Collapse Hierarchy or remove dead code. |
#Refactoring techniques: before and after
Refactoring changes structure while preserving observable behavior. These independent, abbreviated examples use business types as application collaborators.
var total = lines.Sum(x => x.Price * x.Quantity);
invoice.Total = total;invoice.Total = CalculateSubtotal(lines); // (1)
decimal CalculateSubtotal(IEnumerable<InvoiceLine> lines) =>
lines.Sum(x => x.Price * x.Quantity);SearchOrders(DateOnly from, DateOnly to, int page, int pageSize);public record OrderSearch(DateOnly From, DateOnly To, int Page, int PageSize);
// Method signature becomes SearchOrders(OrderSearch query). // (2)decimal Fee(string kind) => kind switch
{
"standard" => 20m,
"express" => 50m,
_ => throw new ArgumentException("Unknown shipping kind.")
};public interface IShipping { decimal Fee(); } // (3)
public sealed class StandardShipping : IShipping
{
public decimal Fee() => 20m;
}
public sealed class ExpressShipping : IShipping
{
public decimal Fee() => 50m;
}
// Resolve the validated kind at the boundary; do not silently use a default.public List<OrderLine> Lines { get; } = [];private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // (4)
public void AddLine(OrderLine line)
{
ArgumentNullException.ThrowIfNull(line);
_lines.Add(line);
}- (1) The calculation name separates how from what without changing the formula.
- (2) The parameter object groups related values; validate range and pagination at an appropriate boundary.
- (3) Each shipping type owns its behavior. Extra types pay off when branching repeats; these amounts are illustrative, and constant extraction is shown in the magic-number card.
- (4) The read-only wrapper prevents casting back to the mutable list. It neither freezes elements nor creates a snapshot; use values and a separate copy when required.
#Safe refactoring, technical debt and code review
Without tests, begin with characterization tests: tests that record what the program currently does, including odd edge cases, so you can tell whether refactoring changed the result. Fix bugs separately so reviewers can distinguish intended behavior changes from code restructuring.
- Identify the problem and improvement: a duplicated rule or difficulty adding a case.
- Establish behavior tests and inspect public contracts (behavior callers rely on) and side effects such as saving data or sending a message.
- Make a small rename or extraction, run tests and inspect the diff.
- Measure hot-path changes and roll out gradually when operational risk warrants it.
Technical debt is a shortcut with a future cost. Record the reason, impact, owner and repayment trigger; old code alone does not justify a rewrite.
Review checklist: correctness, edge cases, authorization, data consistency, cancellation, secret-free logs, behavior tests, naming and diff size. Distinguish blockers, suggestions and nits.
#Analyzers, .editorconfig and nullable warnings
Conventions enforced only by review consume attention. Let the compiler and analyzers catch repeatable mistakes. These settings suit a 10 project; adopt rules incrementally in existing code rather than suppressing every warning.
<!-- In the project file -->
<PropertyGroup>
<Nullable>enable</Nullable>
<AnalysisLevel>10.0</AnalysisLevel>
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>
# .editorconfig
root = true
[*.cs]
indent_style = space
indent_size = 4
dotnet_diagnostic.CA2200.severity = warning
csharp_style_var_for_built_in_types = false:suggestion
Nullable enables null-state analysis, while WarningsAsErrors makes the nullable warning group fail the build. CA2200 flags rethrows that lose stack information. A style suggestion does not necessarily fail ; configure severity and build enforcement when that is required.
| Older practice | Current approach |
|---|---|
| Scattered null checks and manual formatting review | Nullable analysis, analyzers and shared .editorconfig |
| Silencing warnings with an unexplained ! | Fix the nullability contract or establish it through validation |
Tools cannot identify a missing business rule. dotnet format --verify-no-changes can check agreed formatting in CI.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
16.1What makes code clean?Junior
I judge clean code by how safely another engineer can understand and change it. I look for meaningful names, cohesive functions, explicit dependencies and visible failure paths. I also want tests around business rules and boundaries, because readability alone does not establish correctness. I do not apply a fixed method length or introduce patterns automatically. A short function with hidden side effects can be harder to maintain than a longer, straightforward calculation.
16.2How do you choose good names?Junior
I choose names that express domain intent and make the call site readable. For example, paymentTimeoutSeconds is clearer than timeout, and ReserveStock is clearer than Process. I use the same term for the same concept throughout the application and distinguish different concepts even when their current implementation matches. I follow C# conventions such as the Async suffix and avoid abbreviations that require local knowledge. If naming is difficult, I check whether the responsibility itself is unclear.
16.3How small should a function be?Junior
I keep a function focused on one coherent operation and roughly one level of abstraction. An orchestration method can validate an order, reserve inventory and save it, while detailed calculations live behind meaningful names. I do not use a universal line limit because a clear mapping or algorithm may need more lines. I extract a method when it introduces a useful concept or isolates complexity, and avoid splitting code so aggressively that reading it requires constant .
16.4Why use guard clauses?Junior
I use guard clauses to handle invalid input and exceptional cases near the beginning of a method, leaving the normal path easy to follow. They reduce indentation and make preconditions visible. During refactoring, I preserve evaluation order, null handling and side effects, because flattening conditions can accidentally change behavior. I also distinguish invalid programming input from expected business rejection. The former may justify an argument exception, while the latter often deserves an explicit result.
16.5When should you write a comment?Junior
I write comments for information the code cannot communicate well, such as a regulatory constraint, a deliberate trade-off or a workaround linked to an issue. I avoid comments that simply repeat the statement, and I rename or extract confusing code before explaining it in prose. Public documentation can describe contracts and failure behavior. I remove commented-out implementations because version control already preserves history, and I update useful comments whenever the relevant behavior changes.
16.6What does DRY actually mean?Mid
I interpret as avoiding multiple authoritative representations of the same knowledge. If the same tax rule is implemented in three places, a policy should own it. However, similar-looking code can represent independent rules, such as a loyalty discount and an invoice adjustment. Combining them can create coupling and complicated flags. I first ask whether the pieces must change together, then extract a shared concept when that relationship is real rather than deduplicating purely by appearance.
16.7How do KISS and YAGNI affect your design?Mid
I use to choose the simplest design that satisfies the actual functional and operational requirements. stops me from building extension points for scenarios nobody currently needs. For example, I would not introduce a plugin framework for one shipping calculation. I still preserve useful boundaries and meet security and reliability requirements, because those are real needs. When a second variation appears, I can refactor using evidence instead of carrying a speculative abstraction indefinitely.
16.8What is the difference between shotgun surgery and divergent change?Mid
Shotgun surgery means one logical change requires coordinated edits across many files, often because a rule is scattered. Divergent change means one class changes for several unrelated reasons, often because it owns too many responsibilities. I address the first by moving related behavior and data together, and the second by extracting cohesive responsibilities. I verify the actual change history before restructuring, because class size or file count alone does not prove either problem.
16.9When would you replace a conditional with polymorphism?Mid
I consider polymorphism when the same type-based branching appears in several operations and each variant has meaningful behavior. A shipping policy can then own its price and delivery rules, reducing coordinated switch changes. I keep construction or selection at a boundary and reject unsupported values explicitly. For one small switch over a stable enum, introducing a hierarchy may add more complexity than it removes. I choose based on repetition and change patterns rather than treating every switch as a defect.
16.10What are tell-don't-ask and the Law of Demeter?Mid
Tell-don't-ask encourages me to request behavior from the object that owns a rule instead of reading its state and reproducing the rule elsewhere. The Law of Demeter limits dependence on a collaborator's internal object graph. For example, order.Cancel can protect valid transitions, and an order shipping snapshot avoids traversing customer internals. I apply these ideas to reduce coupling and protect , while keeping simple data transfer and query projections straightforward.
16.11Why prefer composition over inheritance?Mid
I prefer composition when behavior varies independently, such as shipping, discounts or payment providers. Injecting a collaborator lets me replace one policy without inheriting unrelated state or protected implementation details. It also makes dependencies visible and tests more focused. Inheritance remains useful when there is a genuine substitutable relationship and a stable base contract. I avoid deep hierarchies created only for code reuse, because base-class changes can unexpectedly affect every derived class.
16.12How do you refactor code that has no tests?Senior
I first identify the behavior and contracts that must remain stable, including failure paths and side effects. I add characterization tests around those boundaries, using a small seam if necessary to isolate a dependency. Then I make one structural change at a time and run the relevant tests. I separate discovered bugs from refactoring so reviewers can see intentional behavior changes. For high-risk paths, I also compare production signals and use a gradual rollout with a clear rollback path.
16.13How do you prioritize technical debt?Senior
I prioritize technical debt by its effect on delivery, reliability and risk rather than by how old or unattractive the code looks. I record the shortcut, its interest cost, the affected area and a practical repayment trigger. Repeated incidents or changes that consistently touch many files are useful evidence. I favor small improvements alongside related feature work, and propose a larger investment only when its expected benefit and migration risk justify the cost.
16.14How do you give effective code review feedback?Senior
I review the intended behavior first, then correctness, security, consistency and test coverage before style. My comments describe a concrete scenario and consequence, and suggest an action or ask a focused question. I label whether something blocks approval or is optional, and explain the trade-off when several solutions are reasonable. I leave mechanical formatting to tooling. If a discussion becomes lengthy, I resolve it directly and record the decision so the author has a clear next step.
16.15What can analyzers and nullable reference types enforce?Mid
I use analyzers to catch repeatable defects and shared conventions, and nullable reference types to make nullability part of the compile-time contract. I can promote nullable warnings to build errors and share formatting or diagnostic severity through .editorconfig. This reduces review noise, but it does not prove the business logic is correct. The null-forgiving operator only suppresses analysis; it does not validate input at runtime. External input still needs validation, and behavior still needs appropriate tests.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
Two similar calculations change for unrelated business reasons. What is safest?
Which comment adds useful information?
One pricing change requires edits in twelve unrelated files. Which smell?
A class changes for shipping, billing and email requirements. Which smell?
What defines a refactoring?
What does a read-only collection wrapper guarantee?
What is the best first step before risky refactoring without tests?
What does the null-forgiving operator ! do?