Do your methods actually do one thing? Most teams swear they do – until a profiler or code review says otherwise.
You write methods every day, but tiny choices – ref
vs in
, Task
vs ValueTask
, local function vs lambda – quietly shape performance, readability, and testability. In this hands‑on guide, I’ll show you how to design, name, and call methods that scale from quick utilities to production APIs. Expect concrete examples, subtle pitfalls, and a few battle‑scars from my 15 years building .NET systems.
What’s a “method” vs a “function” in C#?
Short answer: in C#, we mostly say method. The term function pops up in two places:
- Local functions: functions declared inside a method.
- Talking about functional style (pure functions, no side effects) or delegates like
Func<T>
andAction
.
Everything else – instance members, static helpers, extension methods – are methods.
Mental model: method is “+object context”; function is “+computation”. C# supports both, but the language’s surface area and tooling are optimized for methods.
The anatomy of a method (signature & shape)
A method signature in C# is name + parameter types + modifiers + return type. Let’s annotate a realistic example:
public sealed class InvoiceService
{
// Public API surface
public async Task<Result<Invoice>> CreateAsync(
CustomerId customerId,
Money amount,
DateTimeOffset dueDate,
CancellationToken ct = default)
{
// 1) Validate early (guard clauses)
if (amount <= Money.Zero) return Result.Fail("Amount must be positive.");
// 2) Local function to keep this method flat & readable
static string BuildReference(CustomerId id, DateTimeOffset when)
=> $"INV-{id.Value}-{when:yyyyMMdd}";
var reference = BuildReference(customerId, dueDate);
// 3) Do I/O last, keep computation separate
var saved = await _repo.SaveAsync(new Invoice(reference, customerId, amount, dueDate), ct);
return Result.Ok(saved);
}
private readonly IInvoiceRepository _repo;
public InvoiceService(IInvoiceRepository repo) => _repo = repo;
}
Highlights:
- Guard clauses keep the happy path straight.
- Local function
BuildReference
avoids polluting the class API. CancellationToken
is optional (= default
) but present – future‑you will thank present‑you.
Parameters: value, ref
, out
, in
, params
, optional & named
Designing parameters is where many performance and usability bugs are born.
Value vs ref
/out
/in
- Value (default): copies for structs; references for classes. Use most of the time.
ref
: pass by reference, allows callee to mutate the caller’s variable. Powerful, but increases cognitive load.out
: callee must assign. Great for Try‑pattern (bool TryParse(string, out T)
), keeps exceptions for exceptional cases.in
: pass by readonly reference. Useful with large readonly structs (e.g.,DateTime
,Guid
are small;decimal
is medium; your custom big struct could benefit). Avoid premature micro‑optimization.
public static bool TryFindById(
ReadOnlySpan<char> input,
out Guid id)
{
// Parses without allocating substrings
return Guid.TryParse(input, out id);
}
params
arrays
Great for small, human‑friendly APIs:
public static string JoinNonEmpty(string separator, params string[] parts)
=> string.Join(separator, parts.Where(p => !string.IsNullOrWhiteSpace(p)));
Optional & named arguments
- Use optional parameters for rarely customized knobs (e.g.,
ct = default
). - Use named arguments at call‑sites to improve clarity for boolean switches:
// Without names: What are those booleans?
CreateUser("alice", true, false);
// With names: instant clarity
CreateUser("alice", isAdmin: true, sendWelcomeEmail: false);
Rule of thumb: if a parameter’s meaning isn’t obvious from its type/name at the call site, require a named argument.
Return types that guide design
void
: fire‑and‑forget only for event handlers. Otherwise, prefer returning a value telling the caller what happened.T
: synchronous computation; good default.Task
/Task<T>
: asynchronous work with potential I/O. Standard forasync
methods.ValueTask
/ValueTask<T>
: micro‑opt for high‑throughput paths where results are often synchronous. Measure first.bool
+out T
: Try‑pattern (e.g.,TryParse
). Avoid exceptions for expected control flow.IEnumerable<T>
/IAsyncEnumerable<T>
: stream results progressively. Rememberawait foreach
for async streams.Result<T>
orOneOf<...>
(custom/nuget): explicit success/failure without exceptions; improves domain clarity.
Example returning multiple values without a DTO:
public static (int Min, int Max, double Avg) Summarize(ReadOnlySpan<int> data)
{
if (data.IsEmpty) return (0, 0, 0);
int min = int.MaxValue, max = int.MinValue, sum = 0;
foreach (var n in data)
{
if (n < min) min = n;
if (n > max) max = n;
sum += n;
}
return (min, max, (double)sum / data.Length);
}
Call‑site:
var (min, max, avg) = Summarize(stackalloc[] { 3, 1, 9, 4 });
Overloading vs defaults: readability first
Overloads are great for meaningfully different shapes. Optional parameters are great for tweaks. If you use both, keep the rules simple:
// Overload expresses different intent
public Stream Open(string path); // use defaults
public Stream Open(string path, FileMode mode); // different intent, different overload
// Optional parameters tweak behavior
public Stream Open(
string path,
FileMode mode,
FileAccess access = FileAccess.Read,
FileShare share = FileShare.Read)
{ /* ... */ }
If a method needs more than ~5 parameters (especially booleans), consider a request object or Builder.
Extension methods: add behavior without inheritance
Extension methods are fantastic for composing domain operations while keeping the type surface minimal.
public static class MoneyExtensions
{
public static bool IsZero(this Money m) => m == Money.Zero;
public static Money Clamp(this Money m, Money min, Money max)
=> m < min ? min : (m > max ? max : m);
}
// Usage
var net = gross.Clamp(Money.Zero, limit);
Guidelines:
- Keep them cohesive and discoverable; group by topic/namespace.
- Don’t hide heavy logic in cute one‑liners; debugging becomes painful.
Local functions vs lambdas (and allocations)
Both are great, but they differ subtly:
- Local functions: compiled as methods; no heap allocations when they don’t capture state.
- Lambdas: also fine; capturing outer variables can allocate a closure.
int SumSquares(int[] data)
{
int Square(int x) => x * x; // local function, no capture
return data.Sum(Square);
}
int SumSquaresWithLambda(int[] data)
{
return data.Sum(x => x * x); // ok; similar IL in this case
}
int SumWithCapture(int[] data)
{
int offset = 10;
return data.Sum(x => x + offset); // captures 'offset' => closure allocation
}
Tip: In hot paths, prefer local functions to avoid accidental captures; in most code, lambdas are perfectly fine.
Async methods that behave under load
- Always accept
CancellationToken
in public async APIs. - Don’t block inside async methods (no
.Result
/.Wait()
). - Use
ConfigureAwait(false)
in libraries to avoid context hops. - Choose
Task
by default; only useValueTask
if a profiler proves it reduces allocations/overhead.
public async Task<User> GetOrCreateAsync(
string email,
CancellationToken ct = default)
{
var existing = await _users.TryGetByEmailAsync(email, ct).ConfigureAwait(false);
if (existing is not null) return existing;
var created = await _users.CreateAsync(new User(email), ct).ConfigureAwait(false);
return created;
}
Exceptions vs Try‑pattern: pick one path
- Use exceptions for truly exceptional situations (I/O errors, contract violations).
- Use Try‑pattern for expected misses/parsing/lookup.
public static bool TryLoadConfig(string path, out AppConfig config)
{
config = default!;
if (!File.Exists(path)) return false; // expected miss -> no exception
var json = File.ReadAllText(path);
config = JsonSerializer.Deserialize<AppConfig>(json)!;
return true;
}
public static AppConfig LoadConfigOrThrow(string path)
{
if (!TryLoadConfig(path, out var cfg))
throw new FileNotFoundException($"Config not found: {path}");
return cfg;
}
Naming & intent: verbs, nouns, and side effects
- Commands (mutate state): use imperative verbs –
CreateUser
,SendEmail
. - Queries (no side effects): use Get/Find/Calculate. Don’t surprise callers.
- Avoid ambiguous verbs:
Process
,Handle
,Manage
. Be concrete. - Prefer positive names:
IsValid
,CanExecute
instead ofNotInvalid
.
Litmus test: if a teammate can guess what the method does from name + signature, you named it well.
Performance notes you can actually use
- Prefer
ReadOnlySpan<T>
/Span<T>
in tight loops to avoid allocations (strings, arrays). Keep APIs ergonomic – don’t span‑ify everything. - Avoid params boxing: beware
params object[]
in hot paths. - Inline small helpers only if profiler suggests.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
is a hint, not a guarantee. - Beware big structs by value: passing a 128‑byte struct around will copy it; consider
in
or redesign. - Don’t allocate in loops: pull lambdas/delegates out, reuse buffers.
Micro‑example (allocation‑free parsing over a buffer):
public static bool TryParseCsvLine(ReadOnlySpan<char> line, Span<Range> fields, out int count)
{
count = 0;
int start = 0;
for (int i = 0; i < line.Length; i++)
{
if (line[i] == ',')
{
if (count >= fields.Length) return false; // not enough slots
fields[count++] = new Range(start, i);
start = i + 1;
}
}
if (count < fields.Length)
{
fields[count++] = new Range(start, line.Length);
return true;
}
return false;
}
Call‑site:
Span<Range> slots = stackalloc Range[8];
if (TryParseCsvLine("42,alice,admin".AsSpan(), slots, out var n))
{
// slice original line using ranges without allocating substrings
}
Make methods testable (without testing private details)
- Keep methods small and pure where possible – pure methods are trivial to test.
- Separate computation from I/O: push I/O to boundaries; keep rules in pure helpers/local functions.
- Test behavior via public API; use private local functions to structure logic without inflating surface area.
public sealed class PasswordPolicy
{
public bool IsStrong(string password)
{
// local helpers
static bool HasDigit(ReadOnlySpan<char> s)
{ foreach (var ch in s) if (char.IsDigit(ch)) return true; return false; }
static bool HasUpper(ReadOnlySpan<char> s)
{ foreach (var ch in s) if (char.IsUpper(ch)) return true; return false; }
return password.Length >= 12 && HasDigit(password) && HasUpper(password);
}
}
Unit test stays focused:
[Fact]
public void Strong_Password_Passes()
{
var p = new PasswordPolicy();
Assert.True(p.IsStrong("Sup3rS3curePass"));
}
Practical API checklist (print this)
- Name expresses verb + object (command) or Get/Calculate (query).
- Parameters ≤ 5; otherwise consider a request object.
- Booleans at call‑site are named arguments.
CancellationToken
on public async APIs.TryXxx
for expected misses; exceptions for exceptional cases.- Local functions to keep methods flat and readable.
- Return type communicates outcome (
bool
+out
,Result<T>
, tuples). - Measure before choosing
ValueTask
/Span<T>
.
Visual: call stack at a glance
Main()
└─ CreateOrderAsync()
├─ Validate(request)
├─ BuildReference(local function)
└─ SaveAsync() // awaits I/O, returns Order
This is the mental picture you want: shallow methods, clear intent, isolated computation.
FAQ: quick answers you’ll need on a sprint
ValueTask
everywhere for speed? No. It adds complexity and can regress performance if awaited multiple times. Start with Task
; switch to ValueTask
only after profiling shows a clear win.
If the helper is used only in one method and relies on that method’s context, use a local function. If it’s reusable across the class, use a private method.
Use Try‑pattern for expected bad input (TryParse
). Reserve exceptions for truly exceptional cases.
in
parameters? With large readonly structs on hot paths. Otherwise, stick to normal by‑value parameters for simplicity.
They’re great when grouped by namespace and named well. Don’t overuse them to hide complex workflows.
More than ~5 is a smell. Introduce a request object with named properties for clarity and future extensibility.
Test through the public method. If a helper needs direct tests, it probably wants to be a private method promoted to internal (and test with InternalsVisibleTo
).
Task
or Task<bool>
? Prefer returning a domain result (Result
/Result<T>
), or Task
if the only failure mode is an exception. Task<bool>
is okay for simple success/fail with no details.
Conclusion: Build small, honest methods that scale
Great C# code is a city of small, well‑named methods: each does one thing, tells the truth through its return type, and doesn’t surprise callers. Start by tightening signatures, extracting local functions, and choosing between exceptions and Try‑pattern. Profile before reaching for spans and ValueTask
. Your future self – and your teammates – will feel the difference.
Which method rule above would clean up the most code in your repo this week? Drop a comment – I read them all and I’m happy to suggest refactors.