How to Apply Functional Programming in C#: A Beginner's Guide
Learn functional programming in C# the simple way: pure functions, immutability, records, LINQ, pattern matching, and composition with friendly examples.
Think about making a fresh cup of chai at home. You take the same water, the same milk, the same tea leaves, the same sugar, and the same flame for the same time. Every single time, you get the same cup of chai. The recipe does not secretly change the sugar jar in your neighbour's kitchen. It does not depend on the mood of the weather. Same ingredients in, same chai out.
That simple idea is the heart of functional programming. You build small steps that take some input and give back an answer, and they do not quietly change things around them. In this guide we will learn how to apply this style in C#, step by step, in plain English, with small examples and pictures. By the end you will be able to write code that is easier to test, easier to read, and far less likely to surprise you.
What is functional programming, really?
Functional programming is a way of thinking about code. Instead of telling the computer how to change things one line at a time, you describe what answer you want by combining small functions.
There are three big ideas you need to hold in your head:
- Pure functions — functions that are honest. Same input, same output, no hidden surprises.
- Immutability — data that does not change after you create it. If you want a change, you make a fresh copy.
- Composition — joining small functions together like train coaches to build bigger behaviour.
C# is what we call a multi-paradigm language. That is a fancy way of saying you do not have to choose. You can write objects and classes like you always have, and sprinkle in functional ideas where they help. Modern C# (C# 14, shipping in .NET 10, the current LTS release) gives you records, LINQ, lambdas, and pattern matching that make this style feel natural.
Pillar one: pure functions
A pure function follows two simple rules:
- Its answer depends only on the values you pass in.
- It changes nothing outside itself (no writing to files, no editing global variables, no printing to the screen as its main job).
Let us look at an impure function first, so you can feel the difference.
// Impure: it depends on something outside (the field 'taxRate')
// and that field could change without this method knowing.
public class Billing
{
public decimal TaxRate = 0.18m;
public decimal AddTax(decimal price)
{
return price + (price * TaxRate);
}
}The problem is that AddTax(100) might give a different answer tomorrow if someone changes TaxRate. The function is not honest about everything it depends on. Now here is the pure version.
// Pure: everything it needs comes in as a parameter,
// and it changes nothing outside. Same inputs => same output, always.
public static class Billing
{
public static decimal AddTax(decimal price, decimal taxRate)
{
return price + (price * taxRate);
}
}
// AddTax(100m, 0.18m) is ALWAYS 118m. Forever. You can trust it.Because the pure version takes everything it needs as input, you can read it once and know exactly what it does. You can test it without setting up a database, a clock, or the internet. And because it touches nothing shared, many threads can call it at the same time safely.
How a pure function behaves
Steps
Input
price and taxRate passed in
Pure function
calculates using only inputs
Output
same answer every time
A quick test you can run in your head
Ask yourself two questions about any function:
| Question | If "yes" | If "no" |
|---|---|---|
| Does it always return the same output for the same input? | Good, stays pure | It is impure |
| Does it change anything outside itself? | It is impure | Good, stays pure |
A pure function answers "yes" to the first and "no" to the second. The more of your functions you can make pure, the calmer your codebase becomes.
Pillar two: immutability
Immutable means "cannot be changed after it is made." Think of a printed train ticket. Once it is printed, you do not scribble a new seat number on it. If your plans change, you get a fresh ticket. The old one stays exactly as it was.
Why is this useful? When data never changes, you never get those nasty bugs where one part of the program edits a value while another part is still using it. No race conditions. No "who changed this?" mysteries.
C# gives us records, which were added in C# 9 and have grown ever since. A positional record is immutable by default, and it comes with a handy with expression for making a changed copy.
// A record: a small, immutable data holder.
public record Order(string Item, int Quantity, decimal Price);
var order = new Order("Notebook", 2, 50m);
// We do NOT change 'order'. We make a fresh copy with one field updated.
var biggerOrder = order with { Quantity = 5 };
// 'order' still says Quantity = 2. 'biggerOrder' says Quantity = 5.
// The original is safe and untouched.The with keyword is the immutable way to "change" something. It copies the record, swaps the fields you mention, and leaves the original alone. This is sometimes called non-destructive mutation — you get your update without destroying the old value.
For collections, the same idea applies. Instead of editing a list in place, functional code prefers methods that return a new list. The System.Collections.Immutable package gives you types like ImmutableList<T> that make this safe and clear.
Pillar three: composition
Composition means joining small functions so the output of one becomes the input of the next. Picture a tiffin factory line. One worker adds rice. The next adds dal. The next packs the box. Each worker does one tiny job, and together they make a finished lunch.
In code, you can do the same. Here is a small example using simple steps.
using System;
public static class TextPipeline
{
// Three small, pure steps.
public static string Trim(string s) => s.Trim();
public static string Lower(string s) => s.ToLowerInvariant();
public static string Hyphenate(string s) => s.Replace(' ', '-');
// Compose them into one "make a slug" step.
public static string MakeSlug(string raw)
{
Func<string, string> pipeline = input =>
Hyphenate(Lower(Trim(input)));
return pipeline(raw);
}
}
// MakeSlug(" Hello World ") => "hello-world"Each step is tiny and easy to check on its own. When you compose them, you build bigger behaviour without writing one giant, tangled method. If a step is wrong, you fix that one small step.
Composing small functions into a pipeline
Steps
Trim
remove spaces at ends
Lower
make all lowercase
Hyphenate
spaces become dashes
LINQ: functional programming you already use
Here is some good news. If you have ever used LINQ, you have already written functional code without realising it. LINQ is built on the very ideas we just learned: small functions, no changing the original, and chaining steps together.
using System.Linq;
using System.Collections.Generic;
var orders = new List<Order>
{
new("Notebook", 2, 50m),
new("Pen", 10, 5m),
new("Bag", 1, 800m)
};
// A functional pipeline: filter, transform, and total.
// Notice: 'orders' is never changed. Each step returns something new.
decimal bigSpend = orders
.Where(o => o.Price >= 50m) // keep expensive items
.Select(o => o.Price * o.Quantity) // turn each into a line total
.Sum(); // add them up
// bigSpend = (50 * 2) + (800 * 1) = 900Look at what happened. We started with a list. We filtered it, transformed it, and summed it. The original orders list is completely unchanged. Each method takes a small function (a lambda like o => o.Price >= 50m) and returns a new sequence. This is functional thinking in its everyday clothes.
Pattern matching: making decisions the functional way
Functional code loves to look at the shape of data and decide what to do. C# gives us pattern matching and switch expressions for exactly this. Instead of a long ladder of if/else statements that change a variable, you describe each case and return a value.
public record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public record Square(double Side) : Shape;
public static class Geometry
{
// A switch expression: one case per shape, each returns an area.
public static double Area(Shape shape) => shape switch
{
Circle c => 3.14159 * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Square s => s.Side * s.Side,
_ => throw new ArgumentException("Unknown shape")
};
}
// Geometry.Area(new Circle(2)) => about 12.566This reads like a small table of rules. Each arm is a tiny pure expression. There is no shared variable being edited along the way. Records and pattern matching are a perfect pair, because a positional record can be matched by its parts.
| Style | How it decides | Functional friendliness |
|---|---|---|
if / else ladder | Run statements, often change a variable | Lower, easy to make mistakes |
switch expression | Match the shape, return a value | Higher, clean and pure |
| Pattern matching | Look at data shape and values | Higher, very expressive |
Avoiding the side effects trap
A side effect is when a function does something beyond returning a value: writing to a file, sending an email, saving to a database, or printing to the screen. Real programs must do these things. So functional style does not ban side effects. It just asks you to push them to the edges.
The idea is simple. Keep the core of your program pure (the calculations and decisions), and do the messy outside-world work (database, network, files) in a thin layer around that core. This keeps most of your code easy to test, with only a small risky border.
Pure core, messy edges
Steps
Read input
get data at the edge
Pure logic
calculate with no side effects
Write output
save or print at the edge
public static class CheckoutCore
{
// Pure: no database, no printing. Just a clean decision.
public static decimal FinalTotal(
IEnumerable<Order> items, decimal taxRate, decimal discount)
{
decimal subtotal = items.Sum(i => i.Price * i.Quantity);
decimal afterDiscount = subtotal - discount;
return Billing.AddTax(afterDiscount, taxRate);
}
}
// The database save happens OUTSIDE this method, in your app's edge.
// This pure core can be unit tested in milliseconds, with no setup.A note on libraries and tools
You may hear about helper libraries for functional C#, such as LanguageExt, which add types for safely handling missing values and errors without throwing exceptions. These are optional. You can apply everything in this guide using plain C# and the standard library.
One honest heads-up while we are talking about the .NET ecosystem: some popular libraries have changed how they are licensed. For example, MediatR and MassTransit are now commercially licensed for many uses. They are not functional-programming tools, but you may meet them in real projects, so it is worth knowing their license terms changed. Always check a library's current license before adding it to your work.
Putting it all together
Let us connect the pieces into one clear mental model. You read data in, you run it through pure functions and pattern matching, you keep your data immutable along the way, and you only touch the outside world at the very start and the very end.
Here is a tiny end-to-end example that uses every idea: records for immutable data, LINQ for the pipeline, a pure function for the rule, and a switch expression for a decision.
public record Student(string Name, int Marks);
public static class Results
{
// Pure rule: turn marks into a grade. Same marks => same grade.
public static string Grade(int marks) => marks switch
{
>= 90 => "A",
>= 75 => "B",
>= 50 => "C",
_ => "Needs work"
};
public static IEnumerable<string> TopReport(IEnumerable<Student> students)
=> students
.Where(s => s.Marks >= 50) // keep passers
.OrderByDescending(s => s.Marks) // best first
.Select(s => $"{s.Name}: {Grade(s.Marks)}"); // label each
}Every line is small, honest, and testable. The students collection is never changed. If a teacher asks "why did Ravi get a B?", you can read Grade in two seconds and know the answer.
When should you reach for functional style?
You do not have to rewrite your whole app. Start small and grow. Functional style shines when:
- You are doing calculations, rules, or data transformations.
- You want code that is easy to unit test.
- You are working with data that many parts of the program share.
Keep objects and classes where they help with structure. Use functional ideas for the logic inside. This blend is exactly what modern C# was designed for.
Quick recap
- Functional programming means building small, honest functions and joining them together, like steps in a chai recipe.
- A pure function depends only on its inputs and changes nothing outside. Same input, same output, every time.
- Immutability means data does not change after creation. Use C# records and the
withexpression to make changed copies safely. - Composition joins small functions so the output of one feeds the next, like a tiffin factory line.
- LINQ is functional programming you already use:
Where,Select, andSumnever change the original. - Pattern matching and switch expressions let you decide by the shape of data and return a value, instead of editing variables.
- Push side effects (database, files, network) to the edges, and keep a pure core that is easy to test.
- You do not have to choose between functional and object-oriented. C# lets you mix both, and that is its strength.
References and further reading
- Pattern matching overview — C# (Microsoft Learn)
- C# record types (Microsoft Learn)
- LINQ in C# (Microsoft Learn)
- Functional Programming in C#: The Practical Parts (Milan Jovanović)
- Functional C# in Practice: Records, Immutability, and Pipelines (Developer's Voice)
- Why Modern C# Encourages Functional Programming Concepts (C# Corner)
Related Posts
Getting Started with C# Records: A Beginner's Friendly Guide
Learn C# records the easy way: value equality, with expressions, positional syntax, and record struct, explained with simple real-life examples.
Functional Programming in C#: The Practical Parts You Will Actually Use
A warm, beginner-friendly guide to functional programming in C#: records, immutability, pattern matching, switch expressions, pure functions, and LINQ.
C# init-only and required Properties: A Beginner's Guide
Learn C# init-only and required properties with simple analogies, diagrams, and code. Build safe, immutable objects that are filled correctly every time.
Records, Anonymous Types, and Non-Destructive Mutation in C#
Learn C# records, anonymous types, and non-destructive mutation with the with expression using simple words, real-life examples, and clear diagrams.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.
The New LINQ Methods from .NET 6 to .NET 9: A Friendly Guide
Learn the new LINQ methods added in .NET 6, 7, 8 and 9 with simple words, real-life examples, diagrams and clean C# code. Great for beginners.