📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does conditional compilation work in C#, and when should you use #if, DefineConstants or the [Conditional] attribute instead of run-time configuration?

Conditional compilation in .NET: preprocessor directives, DefineConstants and implicit symbols (DEBUG, TFM and OS symbols), the [Conditional] attribute's call-site semantics, feature switches, and why most environment differences belong in run-time configuration.

Intermediate Theory Intermediate build-system conditional-compilation define-constants multi-targeting preprocessor

🎯 Key points

  • #if, #elif, #else and #endif include or exclude code at compile time based on defined symbols
  • Symbols come from DefineConstants in the project, #define at the top of a file, and implicit SDK symbols
  • Implicit symbols include DEBUG/TRACE, TFM symbols (NET10_0, NET10_0_OR_GREATER, NETSTANDARD2_0) and OS symbols for OS-specific TFMs (WINDOWS, ANDROID, IOS)
  • Append to DefineConstants with $(DefineConstants);MY_SYMBOL, or you'll drop the SDK's symbols
  • [Conditional("SYMBOL")] on a void method removes calls when the symbol isn't defined in the calling code, including argument evaluation
  • Use conditional compilation for platform or framework differences, not for environment settings or feature toggles
  • Every symbol combination is a different program that must be built and tested

⏱ 30-second answer

Conditional compilation decides at compile time which code exists. #if/#else/#endif test symbols. Those come from DefineConstants in the project, #define in a file, or implicit SDK symbols: DEBUG, framework ones like NET10_0_OR_GREATER, and OS ones like WINDOWS for net10.0-windows. The [Conditional("DEBUG")] attribute on a void method is different. The method is always compiled, but calls to it are removed wherever the symbol isn't defined in the calling code, including the evaluation of their arguments. I use these for framework and platform differences, such as multi-targeting. Anything that varies by environment or customer belongs in run-time configuration or feature flags, so one build can be deployed everywhere.

🗣 Full answer (senior)

How I would answer this in an interview

"Conditional compilation decides at compile time which code exists at all. #if, #elif, #else and #endif test symbols, and code in an excluded branch isn't compiled, so it can even use APIs that don't exist for that target. The symbols come from DefineConstants in the project, #define at the top of a file, or implicit SDK symbols: configuration ones like DEBUG and TRACE, framework ones like NET10_0_OR_GREATER or NETSTANDARD2_0 when you multi-target, and OS ones like WINDOWS, but only for an OS-specific TFM such as net10.0-windows.

The mistake I see is overwriting DefineConstants: you have to append with $(DefineConstants);MY_SYMBOL, or DEBUG and every framework symbol silently disappear. The [Conditional("DEBUG")] attribute works differently. The void method is always compiled, but calls to it are removed wherever the symbol isn't defined in the calling code, including the evaluation of their arguments, so a side effect inside Debug.Assert is a classic cause of Release-only bugs.

I use conditional compilation for framework and platform differences, not environment settings or feature toggles. Anything that varies by environment or customer belongs in run-time configuration or feature flags, and security checks never sit behind a symbol. Every symbol combination is a different program that has to be built and tested, so I keep that matrix small and explicit in CI, isolate platform code in separate files when the blocks grow, and keep one artefact moving through every environment."


Directives

#if NET10_0_OR_GREATER
    // .NET 10 API
#elif NET8_0_OR_GREATER
    // fallback
#else
    // netstandard2.0 fallback
#endif

Symbols can be combined with &&, || and !. Code in an excluded branch isn't compiled at all, so it can even contain APIs that don't exist for that target.

Where symbols come from

Source Example
Configuration DEBUG;TRACE (Debug), RELEASE;TRACE (Release)
Target framework NET, NET10_0, NET10_0_OR_GREATER, NET8_0_OR_GREATER, NETSTANDARD2_0, NETFRAMEWORK
OS-specific TFM WINDOWS for net10.0-windows, ANDROID, IOS, MACCATALYST
Your project <DefineConstants>$(DefineConstants);ENABLE_TELEMETRY</DefineConstants>
A single file #define VERBOSE (must be before any code)

Always append to DefineConstants. Writing <DefineConstants>ENABLE_TELEMETRY</DefineConstants> replaces the SDK's symbols, and DEBUG silently disappears.

The [Conditional] attribute

[Conditional("DEBUG")]
public static void DumpState(Order order) => Console.WriteLine(order);
  • The method is compiled in every configuration.
  • Calls to it are removed when DEBUG isn't defined in the assembly that contains the call.
  • Arguments aren't evaluated either, so DumpState(LoadOrder()) doesn't call LoadOrder() in Release.
  • Only void methods can be conditional.

That call-site rule matters for libraries: a Release library whose callers are Debug builds will still have its [Conditional("DEBUG")] methods called.

When to use what

Need Tool
Different API per target framework #if NET10_0_OR_GREATER
Platform-specific code in a multi-platform library OS-specific TFM symbols, or OperatingSystem.IsWindows() at run time
Debug-only diagnostics [Conditional("DEBUG")] or Debug.*
Environment settings (URLs, log levels) Run-time configuration
Turning features on for some customers or a gradual rollout Feature flags (for example Microsoft.FeatureManagement)

Production example

A team used a custom STAGING symbol to skip payment-provider signature checks in staging builds. A pipeline change built the production artefact with the staging configuration, and production briefly accepted unsigned callbacks. The fix removed the symbol entirely: signature checks always run, and staging uses the provider's sandbox keys from configuration. The team also adopted "build once, deploy everywhere".

Best practices, performance and security

  • Keep #if blocks small; push differences behind an interface with per-target implementations if they grow.
  • Test every symbol combination you ship (multi-targeting does this per TFM automatically).
  • Never make security behaviour depend on compile-time symbols.
  • For trimming and AOT, use the runtime's feature switches (AppContext switches set through RuntimeHostConfigurationOption) when you want code removed at publish time but still controlled by configuration.

🔍 What the interviewer is checking

Checks whether the candidate knows the mechanics and, more importantly, when not to use them.

Green flags: lists symbol sources correctly; explains [Conditional] call-site behaviour; appends DefineConstants; prefers run-time configuration for environments.

Red flags: per-environment #if blocks; security behind symbols; doesn't know TFM symbols.

📚 Learn in depth

1. When each decision is made

  Project file                     Compile time (csc)                     Publish time (trimmer/AOT)       Run time
  ------------------------         --------------------------------       ---------------------------      -----------------------
  DefineConstants = TRACE;X        #if X -> code included or not          feature switches can remove      IConfiguration, env vars
  TargetFramework = net10.0  --->  NET10_0_OR_GREATER defined       --->  guarded code when the switch --->  feature flags
  Configuration  = Release         [Conditional("DEBUG")] calls           is a constant                    OperatingSystem.IsWindows()
                                   removed at the CALL SITE

  different symbols => different IL => a different program to build and test

The further right a decision is made, the fewer binaries you have to build and test. Prefer run time unless you have a reason not to.

2. The directives

Directive Purpose
#if / #elif / #else / #endif Include code only when a symbol expression is true
#define / #undef Define or remove a symbol for the rest of one file; must appear before any other code
#warning / #error Emit a compiler warning or error, for example #error This target is not supported
#nullable, #pragma warning Not conditional compilation, but also processed by the compiler per file

Expressions support &&, ||, !, ==, != and parentheses:

#if NET10_0_OR_GREATER && !BROWSER
    // .NET 10+, not WebAssembly
#endif

Code in an inactive branch isn't compiled at all, so it can use APIs that don't exist for that target, but it also isn't checked by the compiler or analysers.

3. Where symbols come from

Source Symbols Notes
Configuration DEBUG;TRACE (Debug), RELEASE;TRACE (Release) The SDK adds the configuration name in upper case; custom Profiling gets PROFILING
Target framework NET, NETCOREAPP, NET10_0, NET10_0_OR_GREATER, NET9_0_OR_GREATER, NET8_0_OR_GREATER; NETSTANDARD, NETSTANDARD2_0; NETFRAMEWORK, NET48 _OR_GREATER symbols exist for every earlier version too
OS-specific TFM WINDOWS, ANDROID, IOS, MACCATALYST, BROWSER Only for TFMs such as net10.0-windows; not defined for plain net10.0
Your project <DefineConstants>$(DefineConstants);ENABLE_X</DefineConstants> Always append
One file #define VERBOSE Rarely a good idea

Check what the compiler actually receives:

dotnet msbuild -getProperty:DefineConstants -p:Configuration=Release -p:TargetFramework=net10.0

Why appending matters, and the command-line trap

<!-- Replaces DEBUG, TRACE and every TFM symbol -->
<DefineConstants>ENABLE_X</DefineConstants>

<!-- Correct -->
<DefineConstants>$(DefineConstants);ENABLE_X</DefineConstants>

-p:DefineConstants=ENABLE_X on the command line is a global property: it overrides every assignment in the project and SDK, so all the implicit symbols disappear for that build. If you must add a symbol from CI, define your own property (for example -p:EnableX=true) and append the symbol conditionally in the project.

4. The [Conditional] attribute in depth

[Conditional("DEBUG")]
public static void Dump(Order order) => Console.WriteLine(order);

// caller
Diagnostics.Dump(LoadOrder());   // in a caller built without DEBUG: no call, and LoadOrder() never runs
Rule Consequence
The method is always compiled It exists in every build of the library
Calls are removed based on the caller's symbols A Release library called from a Debug app still runs its [Conditional("DEBUG")] methods; NuGet packages (built in Release) never run their own Debug.Assert calls
Arguments are not evaluated when the call is removed Side effects in arguments vanish
Only void methods Methods returning values can't be conditional
Not allowed on overrides or interface implementations CS0243 and CS0629
Can be applied to attribute classes Applications of that attribute are removed when the symbol isn't defined
Several attributes combine with OR [Conditional("DEBUG"), Conditional("TRACE")] keeps the call if either is defined

Debug.* methods are [Conditional("DEBUG")]; Trace.* methods are [Conditional("TRACE")], which is why tracing still works in Release.

5. Choosing the right tool

Need Tool Why
An API exists only on newer frameworks #if NET10_0_OR_GREATER The other branch must compile without that API
Platform-specific code in a cross-platform build OperatingSystem.IsWindows() plus [SupportedOSPlatform("windows")] One binary; the CA1416 analyser checks guards
Platform-specific code in an OS-specific TFM #if WINDOWS, or separate files per platform The code can't compile elsewhere
Debug-only diagnostics [Conditional("DEBUG")], Debug.Assert Removed from Release without #if noise
Remove code at publish time for trimming or AOT Feature switches (RuntimeHostConfigurationOption, [FeatureSwitchDefinition] in .NET 9+) One source, smaller output, still controlled by configuration
Different URLs, keys, log levels per environment IConfiguration, options, Key Vault Same binary in every environment
Turn features on per customer or roll out gradually Feature flags (Microsoft.FeatureManagement, Azure App Configuration) Changed without a deployment

6. Organising platform and framework code

When #if blocks grow, move the differences out of method bodies:

Clock.cs              public partial class Clock { public partial DateTimeOffset Now(); }  (shared)
Clock.net10.cs        partial implementation using TimeProvider (included for net10.0)
Clock.netstandard.cs  partial implementation for netstandard2.0
<ItemGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
  <Compile Remove="**/*.netstandard.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <Compile Remove="**/*.net10.cs" />
</ItemGroup>

Each file then reads as normal code, and reviewers see platform differences in one place.

7. Testing every combination

  • Two TFMs times Debug and Release is already four different programs.
  • Multi-targeted projects build every TFM by default; make sure the test projects multi-target too, so tests run for each one.
  • Add an OS matrix in CI where OS symbols or OS-specific TFMs are used.
  • Analysers and refactorings only see active branches. In the IDE, switch the target framework picker to check the other branches.

8. Performance

  • Conditional compilation has no run-time cost: removed code doesn't exist.
  • Run-time checks such as OperatingSystem.IsWindows() are JIT intrinsics and fold to constants, so they cost essentially nothing either. That removes the main performance argument for #if.
  • Feature switches let the trimmer remove whole subsystems at publish time, which matters for AOT and mobile apps.

9. Security

  • Never make authentication, signature validation or authorisation depend on a compile-time symbol. A pipeline change can ship the wrong variant, and reviewers can't see which variant was deployed.
  • Use configuration (sandbox keys, test tenants) for non-production differences, so the security code path is identical everywhere.
  • Don't hide secrets in #if DEBUG blocks; they're still in source control and in any Debug build that escapes.

10. .NET 11 awareness

Each new framework adds its own symbols (NET11_0, NET11_0_OR_GREATER) automatically when you add the TFM. Avoid adding #if NET11_0_OR_GREATER code to production libraries until .NET 11 is released and you actually ship that target. Keep production on net10.0 until then.

11. Try it yourself

  1. Add <DefineConstants>$(DefineConstants);ENABLE_TELEMETRY</DefineConstants> and an #if ENABLE_TELEMETRY block; build Debug and Release and print DefineConstants for each.
  2. Change it to <DefineConstants>ENABLE_TELEMETRY</DefineConstants> and watch #if DEBUG stop working.
  3. Build with -p:DefineConstants=ENABLE_TELEMETRY and check which symbols survive.
  4. Add a [Conditional("DEBUG")] method called with LoadOrder() as its argument, log inside LoadOrder, and run in Release.
  5. Put the conditional method in a class library built in Release and call it from a Debug console app. Does it run?
  6. Multi-target net8.0;net10.0, use #if NET10_0_OR_GREATER, and switch the IDE's framework picker to see the other branch light up.
  7. Replace an #if WINDOWS block with OperatingSystem.IsWindows() and [SupportedOSPlatform("windows")], and see what CA1416 reports when the guard is missing.

💻 Code

<PropertyGroup>
  <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
  <!-- Append: keep DEBUG/TRACE and TFM symbols -->
  <DefineConstants>$(DefineConstants);ENABLE_DIAGNOSTIC_DUMPS</DefineConstants>
</PropertyGroup>
using System.Diagnostics;

namespace Orders.Domain;

public sealed record Order(Guid Id, decimal TotalInclGst);

public static class Diagnostics
{
    // Calls disappear from callers compiled without DEBUG
    [Conditional("DEBUG")]
    public static void Dump(Order order) => Console.WriteLine($"[debug] {order}");
}

public sealed class OrderLock
{
#if NET10_0_OR_GREATER
    private readonly Lock _gate = new();
#else
    private readonly object _gate = new();
#endif

    public void Run(Action action)
    {
        lock (_gate)
        {
            action();
        }
    }
}

public sealed class PaymentCallbackValidator(IConfiguration configuration)
{
    // Environment differences come from configuration, never from #if
    private readonly string _signingKey = configuration["Payments:SigningKey"]
        ?? throw new InvalidOperationException("Payments:SigningKey is not configured.");

    public bool IsValid(string payload, string signature) =>
        CryptographicOperations.FixedTimeEquals(
            Convert.FromHexString(signature),
            HMACSHA256.HashData(Encoding.UTF8.GetBytes(_signingKey), Encoding.UTF8.GetBytes(payload)));
}

The validator assumes using System.Security.Cryptography;, using System.Text; and using Microsoft.Extensions.Configuration;.

⚠ Common mistakes

  • Mistake: overwriting DefineConstants instead of appending. <DefineConstants>ENABLE_X</DefineConstants> replaces DEBUG, TRACE and every TFM symbol, so #if DEBUG and #if NET10_0_OR_GREATER silently stop working. Instead: always write <DefineConstants>$(DefineConstants);ENABLE_X</DefineConstants>.

  • Mistake: passing -p:DefineConstants=... from CI. It's a global property, so it overrides the project and SDK assignments and removes all implicit symbols for that build only, which is hard to spot. Instead: pass your own property (-p:EnableX=true) and append the symbol in the project with a condition on that property.

  • Mistake: putting environment or customer behaviour under #if. Each symbol combination is a different binary, so the artefact you tested isn't the one you deploy, and a pipeline mistake ships the wrong variant. Instead: build once and use run-time configuration for environments and feature flags for customers or gradual rollouts.

  • Mistake: making security checks depend on symbols. Skipping signature validation in a STAGING build is one pipeline change away from production accepting unsigned requests. Instead: keep security code identical in every build, and point non-production environments at sandbox keys and test tenants through configuration.

  • Mistake: putting side effects in arguments to [Conditional] methods. When the call is removed, the arguments aren't evaluated either, so the work silently disappears in Release. Instead: do the work in a separate statement, store the result, and pass the variable.

  • Mistake: assuming [Conditional] is decided where the method is defined. It's decided at the call site, using the caller's symbols. A Release library still runs its debug helpers for Debug callers, and NuGet packages never run their own Debug.Assert. Instead: design libraries so their behaviour doesn't depend on which configuration the caller was built with.

  • Mistake: large #if blocks inside methods. Inactive branches aren't compiled, analysed or refactored, so they rot unnoticed and make the code hard to read. Instead: move platform or framework differences into separate files or partial classes, included per target with conditional Compile items.

  • Mistake: building only one symbol combination in CI. A NETSTANDARD2_0 or WINDOWS branch breaks and nobody notices until a consumer on that target upgrades. Instead: build and test every TFM (and every OS, where OS symbols are used) in CI.

  • Mistake: using #if WINDOWS without an OS-specific TFM. The symbol only exists for targets such as net10.0-windows, so on net10.0 the Windows branch is never compiled. Instead: use OperatingSystem.IsWindows() with [SupportedOSPlatform("windows")] in cross-platform projects, and OS symbols only with OS-specific TFMs.

  • Mistake: using #if to keep trimmed or AOT builds small. It creates separate builds just to remove code. Instead: use feature switches (RuntimeHostConfigurationOption and [FeatureSwitchDefinition]), which let the trimmer remove code at publish time while keeping one source and one build.

🏛 Senior insight: trade-offs

Every compile-time symbol creates another program that has to be built, tested and supported. A senior engineer uses conditional compilation narrowly, for framework and platform differences that genuinely can't be handled at run time, and pushes everything else into configuration and feature flags so one artefact moves through every environment.

The cost of #if grows with the number of combinations: two TFMs times Debug/Release is already four programs. Seniors keep that matrix explicit in CI (multi-targeted projects build every TFM by default, so run the tests for every TFM too, and add an OS matrix where OS symbols are used), and prefer isolating platform code in separate files or partial classes over sprinkling #if through methods.

They also know the subtle semantics (call-site [Conditional], appending DefineConstants, OS symbols tied to TFMs) because those are exactly where production surprises come from.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses NET10_0_OR_GREATER. Why: Checks where TFM symbols come from and when to multi-target.
    How to answer

    A target framework moniker, or TFM, such as net10.0, tells the SDK which API surface to compile against and which runtime the app needs. OS-specific TFMs like net10.0-windows add platform APIs such as WPF. For applications I target one framework, normally the newest LTS: that's net10.0 today, supported until November 2028. .NET 8 and .NET 9 both reach end of support on 10 November 2026, so they need upgrading now. Libraries can multi-target with <TargetFrameworks>net8.0;net10.0</TargetFrameworks> while they still have consumers on older versions. That builds the library once per framework, and #if NET10_0_OR_GREATER handles API differences. .NET 11 is a preview, so it's for experiments only.

  2. When: Candidate mentions DEBUG and TRACE symbols. Why: Checks what the configuration changes besides symbols.
    How to answer

    A configuration is a named set of build settings. Debug turns optimisation off and defines the DEBUG and TRACE symbols, so the debugger can show every variable and step line by line. Release turns optimisation on and defines RELEASE and TRACE instead of DEBUG. Both produce PDBs by default. Behaviour can differ for three reasons. Code under #if DEBUG, and calls such as Debug.Assert, are removed from Release. The optimising compiler and JIT inline and reorder code and end variable lifetimes earlier, which changes timing and when objects are garbage collected. And race conditions hidden by slow Debug code start to show. dotnet build defaults to Debug, while dotnet publish and dotnet pack default to Release, so you should test the build you ship.

  3. When: Candidate warns about side effects in Debug.Assert. Why: Debug-only code is a classic cause of Release-only bugs.
    How to answer

    First I reproduce it with the same Release build, using dotnet run -c Release or the published artefact, because a Debug build is a different program. Then I check for code that differs between the two: #if DEBUG blocks, Debug.Assert with side effects, and debug-only settings. If nothing differs, I look for things optimisation exposes. A data race or a non-volatile flag that a loop reads once and caches. An object collected early because only a local variable held it, such as a timer. Or plain timing changes. For evidence I use production logs, a dump opened with the matching PDBs, and dotnet-counters. Then I fix the real bug with proper synchronisation, Volatile or Interlocked, or by holding references in fields. Finally I make CI test the Release build.

↩ Usually asked after

More in Build System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me