Module 1: Setting Up Your Toolkit
Your Development Environment
Install and configure VS Code, the .NET SDK, Node.js, and SQL Server - the four tools that power every full-stack project in this course.
Learning Objectives - Install Visual Studio Code and explain how it differs from a full IDE
- Install and verify the .NET SDK, Node.js, and SQL Server on your machine
- Add the essential VS Code extensions for C#, React, and SQL work
- Use the integrated terminal to run commands and check tool versions
- Explain the role each tool plays in a full-stack project
What You'll Learn - What Visual Studio Code is and how it differs from Visual Studio
- Installing the .NET SDK and running dotnet --version
- Installing Node.js and npm for React development
- Installing SQL Server Express or Developer Edition
- Must-have VS Code extensions: C# Dev Kit, ESLint, SQL Server (mssql)
- Using the integrated terminal inside VS Code
- Verifying every tool with version checks before writing a single line of code
What Is Visual Studio Code?
A free, extensible editor - not a full IDE
Visual Studio Code (VS Code) is a free, lightweight code editor built by Microsoft. It is where you will write your C#, JavaScript, and SQL - and it understands each language well enough to flag mistakes before you run anything. Think of it as a very smart text editor that gains extra skills through small add-ons called
extensions. It is open-source, updated monthly, and used by millions of developers as their daily driver.
Many beginners confuse VS Code with
Visual Studio (no "Code"). They are entirely different products. Visual Studio is a heavier full IDE (Integrated Development Environment) designed primarily for Windows .NET development. It installs slowly, consumes significant memory, and targets teams building large enterprise applications. VS Code, by contrast, starts up in seconds and runs on Windows, macOS, and Linux equally well. For this course - where you will work across C#, JavaScript, and SQL in one session - VS Code is the clear choice.
The three zones you will use constantly
When you open VS Code you will see three main zones. The left sidebar holds a file explorer, a search panel, and the extensions marketplace icon (the puzzle-piece). The large central editor area is where you write code: it colours keywords, underlines problems, and suggests completions as you type. At the bottom is the
integrated terminal - a full command-line window built directly into the editor. You will run programs, install packages, and check tool versions without leaving VS Code at all.
Getting comfortable with these three zones early pays dividends in every lesson that follows. The shortcut
Ctrl+` (the backtick key, usually above Tab) toggles the terminal open and closed - you will press it dozens of times per session. The shortcut
Ctrl+Shift+P opens the Command Palette, which lets you search for any editor action by name. Between those two shortcuts, you can do almost anything without reaching for a mouse.
Why extensions matter
VS Code out of the box does not know much about C# or SQL. You teach it by installing extensions from the built-in marketplace. A small, focused set - one for C#, one for JavaScript, one for databases - covers everything in this course. Extensions add syntax highlighting, error detection, code completion, and debugging. The editor without extensions is competent; with the right extensions it rivals any full IDE for everyday development work.
Watch video: What Is Visual Studio Code?
Key Insight: VS Code is just an editor by default. Its real power comes from the extensions you install - one set for C#, one for JavaScript/React, and one for SQL.
Real-World Example: Press Ctrl+` (the backtick key, usually above Tab) inside VS Code to open or close the integrated terminal. You will use this shortcut dozens of times per session.
Q: What is the main practical difference between Visual Studio Code and Visual Studio?
VS Code is a lightweight, cross-platform editor that becomes powerful through extensions. Visual Studio is a much larger Integrated Development Environment focused mainly on Windows and .NET. They are completely separate products made by the same company.
What tools do you currently use to write or edit code, scripts, or text files? How does VS Code sound compared to what you use now?
Installing the .NET SDK for C#
What the SDK includes and why you need it
To write and run C# programs you need the
.NET SDK (Software Development Kit). The SDK contains three things: the C# compiler that translates your code into something the computer can execute, the
dotnet command-line tool you use to create, build, and run projects, and a set of class libraries that give you ready-made building blocks like file I/O, HTTP requests, and database access. Without the SDK, you have no way to turn C# source code into a running program.
Choosing the right version
Visit the official Microsoft .NET website and download the latest
LTS (Long-Term Support) version for your operating system. LTS releases are maintained for three years and stay stable, which makes them the sensible choice while learning. Avoid preview versions - they can change or break between updates. As of June 2026,
.NET 10 is the current LTS release (released November 2025) and the recommended choice for new projects. Run the installer and accept all defaults. Close and reopen VS Code afterward so it picks up the new tool.
The version check habit
To confirm the installation worked, open the integrated terminal and type
dotnet --version, then press Enter. A version number like
10.0.100 or
8.0.404 confirms success. If instead you see "command not found" or "not recognized," the installer did not finish correctly or your terminal needs a full restart - close all terminal windows, reopen VS Code, and try again. This
version check habit is one of the most valuable early habits in development. It tells you instantly whether a tool is installed, which version you have, and whether the terminal can actually find it on its path.
With the SDK installed, also install the
C# Dev Kit extension in VS Code. This gives you real-time code colouring, error highlighting as you type, smart auto-completion (IntelliSense), and an integrated debugger for C# projects. Together, the SDK and the C# Dev Kit extension turn VS Code into a capable C# development environment without needing the full Visual Studio IDE.
Watch video: Installing the .NET SDK for C#
Key Insight: Always choose the LTS version of the .NET SDK while learning. It stays supported and stable for years - far less likely to introduce unexpected breaking changes.
Real-World Example: After installing, run 'dotnet --version' in the terminal. If you see a version number like 8.0.404 or 10.0.100, the SDK is working. If you see an error, restart VS Code first - that fixes most cases.
Q: After installing the .NET SDK, how do you confirm it is working correctly?
Running 'dotnet --version' prints the installed version number if the SDK is correctly installed and the terminal can find it. A version check is the standard way to verify any command-line tool after installation.
Action step: Open a terminal on your machine right now and type 'dotnet --version'. What does it say? If it is not installed yet, which operating system are you on?
Installing Node.js for React
Why React needs Node.js
React is a JavaScript library that ultimately runs in the browser, but to build a React project on your computer you need
Node.js. Node.js lets you run JavaScript outside a browser - on your local machine - and it comes bundled with
npm (Node Package Manager), the tool that downloads and manages the many code libraries a React project depends on. Without Node.js, you cannot create a React project, run its development server, or install any of its dependencies.
Installing and verifying
Download Node.js from the official nodejs.org website and, just like with .NET, select the
LTS version. The installer adds both Node and npm at once. Restart VS Code after installation, then verify with two terminal commands:
node --version should print something like
v24.1.0, and
npm --version should print something like
10.9.0. If either command is not found, restart the terminal and try again before reinstalling.
The node_modules folder explained
When you create a React app, npm will download dozens of packages into a folder called
node_modules. That folder can grow to several hundred megabytes and contain thousands of files - it is never shared or committed to version control manually. Instead, a small file called
package.json records exactly which packages and versions your project needs. Anyone who clones your project runs
npm install and npm recreates node_modules from that recipe file. This is why you always see node_modules in a .gitignore file: you share the recipe, not the ingredients.
Alternative package managers
npm is the original and most widely documented option, making it the right choice while you are learning. As you grow more experienced you may encounter
pnpm (faster, more disk-efficient) or
Bun (extremely fast, newer). Both work with the same package.json format, so switching later requires almost no relearning. For this course, npm is all you need.
Key Insight: npm comes free with Node.js. Installing Node gives you both the JavaScript runtime AND the package manager - the two tools React development requires on your local machine.
Real-World Example: Run 'node --version' to see something like v24.1.0, and 'npm --version' to see something like 10.9.0. Two separate tools, one installation.
Q: What does npm do in a React project?
npm (Node Package Manager) installs and manages the packages a project needs, based on the list in package.json. It creates the node_modules folder containing all those downloaded libraries. Node.js provides the JavaScript runtime, while npm handles the dependencies.
Have you ever encountered the node_modules folder or a package.json file in a project? What did you think it was for at the time?
Installing SQL Server
The database engine options
Your application data needs somewhere to live permanently between user sessions. That is the job of a
database engine, and for this course we use
Microsoft SQL Server (MS SQL). It stores information in organised tables and lets you query, insert, update, and delete that information using the SQL language. Microsoft offers two free editions that cover everything you need while learning:
SQL Server Express (compact and simple to install, with a 10 GB per-database limit) and the
Developer Edition (full-featured but licensed only for development and testing, not production deployment). Either works perfectly for this course.
Management tools
After installing the database engine, you also need a tool to explore and manage your data visually. On Windows,
SQL Server Management Studio (SSMS) is the traditional choice - comprehensive but Windows-only. The recommended cross-platform option is the
SQL Server (mssql) extension for VS Code, which Microsoft officially recommends for database management and SQL development on all platforms. It lets you write and run queries, browse schemas, and view results as a table directly inside VS Code - no separate application needed.
Running SQL Server on macOS and Linux
SQL Server does not install natively on macOS or Linux. The standard approach is to run it inside a
Docker container using Microsoft's official image. This sounds intimidating, but Microsoft provides a clear step-by-step guide that reduces it to a handful of terminal commands. Once the container is running, it behaves identically to the Windows installation and you connect to it using the mssql extension in VS Code on localhost port 1433 - the same as Windows.
Once you have the engine and a management tool in place, create a test database, run a simple SELECT query, and confirm the connection works before moving to Module 5 where you will build tables and write real queries.
Key Insight: SQL Server Express and the Developer Edition are both completely free. You do not need a paid licence to learn, develop, or test with MS SQL Server.
Real-World Example: A learner on a Mac runs: docker pull mcr.microsoft.com/mssql/server then starts the container with the right environment variables. The mssql extension in VS Code then connects to localhost on port 1433 - the same connection string as Windows.
Q: Which two free editions of SQL Server are suitable for learning and development?
SQL Server Express (compact, simple to install) and the Developer Edition (full-featured but restricted to development and testing use) are both free. They include everything needed for learning without requiring a paid licence.
What database have you worked with before, if any - Excel, Access, SQLite, MySQL? How do you think SQL Server compares in terms of scale and features?
Essential VS Code Extensions
Teaching the editor each language
Out of the box VS Code does not deeply understand any specific language or database. You teach it by installing
extensions from the built-in Extensions panel (the puzzle-piece icon in the left sidebar, or Ctrl+Shift+X). For this course, a small, focused set covers everything you need without cluttering the workspace.
Extensions for each technology
For C#, install the
C# Dev Kit (published by Microsoft). This bundle includes the core C# language server, IntelliSense (smart autocomplete that suggests methods and properties as you type), live error detection that underlines mistakes in real time, and an integrated debugger. The difference is immediate: type a class name followed by a dot and VS Code suggests every available method with documentation shown on hover. For React and JavaScript, install
ESLint - it scans your files for common mistakes like unused variables and potential null errors as you type. Pair it with
Prettier if you want automatic consistent formatting every time you save. For databases, install the
SQL Server (mssql) extension, published by Microsoft. Once connected to your database, you can write and run SQL queries directly inside VS Code and see results as a table in a panel below your query - no need to switch to a separate database management application for quick queries.
The lean workspace principle
Resist the urge to install every extension that looks useful. Each extension adds a small amount of overhead, and a workspace with thirty extensions starts to feel cluttered and slow. The rule that serves you well long-term is: add an extension only when you hit a genuine need, not in advance. Start with the four described above, get comfortable with them across the first two or three modules, and add others only when you find yourself repeatedly wishing for a feature you do not have. A lean, focused workspace is almost always more productive than one packed with untested tools.
Key Insight: Install extensions deliberately, not in bulk. A focused set - C# Dev Kit, ESLint, and the mssql extension - covers all three technologies in this course without cluttering your workspace.
Real-World Example: After installing the C# Dev Kit, type 'Console.' inside a C# file. VS Code immediately shows a dropdown of every method available on Console, including WriteLine, ReadLine, and Error - that is the extension helping you write correct code faster.
Q: Which VS Code extension enables code completion, live error detection, and debugging for C# files?
The C# Dev Kit (published by Microsoft) provides IntelliSense code completion, live error detection, and an integrated debugger for C# projects. ESLint and Prettier are for JavaScript/TypeScript, while the mssql extension is for database queries.
Have you ever had an IDE or editor highlight a mistake before you ran the code? How did that change the way you worked?
Your Companion Code Repository: Chello
What Chello is
Chello is a 40-lesson companion code repository for this course, available at
github.com/rickysoo/Chello. It covers the complete journey from C# basics all the way through a fully deployed full-stack application — the same stack you are learning here: C#, ASP.NET Web API, SQL Server, HTML, CSS, and React. Each lesson folder contains the real code that brings one concept to life.
How to use it
You do not need to clone Chello right now. But having it open alongside this course turns theory into practice. When a module explains how a Web API controller works, the corresponding Chello lesson shows you the actual C# file. When you learn about React components, the matching lesson has the working JSX. The repository also includes an interactive progress dashboard (
index.html) you can open in a browser to track which lessons you have completed.
The big picture
The 40 lessons in Chello are grouped into six phases that mirror the modules of this course: C# basics, SQL and databases, Web API, HTML and CSS, React, and finally a full-stack project that ties everything together. By the time you finish both this course and the Chello lessons, you will have built every layer of a real deployed application from scratch.
Key Insight: Chello is your companion code repo at github.com/rickysoo/Chello — 40 lessons of working C#, SQL, Web API, and React code that goes hand in hand with the theory you learn here.
Real-World Example: Visit https://github.com/rickysoo/Chello. Open index.html in a browser to see the progress dashboard. Each Lesson folder contains the code for that lesson — Lesson15 onwards covers the Web API content in this module.
Q: What is Chello?
Chello (github.com/rickysoo/Chello) is a 40-lesson companion repository covering C#, ASP.NET Web API, SQL Server, HTML, CSS, and React — the same stack as this course. Each lesson folder contains the code that goes with the theory you are learning.
Module 2: C# Programming Fundamentals
Variables, Logic, and Methods
Learn the core building blocks that almost every programming language shares - using C# to write, run, and reason about your first real programs.
Learning Objectives - Create and run a C# console application from the terminal
- Declare variables with appropriate data types and explain why types matter
- Use operators and conditional statements to make decisions in code
- Write loops to repeat work without duplicating lines
- Define and call methods to organise and reuse logic
What You'll Learn - Creating a console app with dotnet new console and running it
- Variables and common data types: int, double, string, bool
- Arithmetic, comparison, and logical operators
- Conditional statements: if, else if, else
- Loops: for, while, and foreach
- Methods: parameters, return values, and the void keyword
- Reading compiler error messages as a learning tool
Your First C# Program
The compile-run loop
Every C# program starts as text in a file, but the computer cannot run that text directly. First, the
compiler translates your C# source code into an intermediate language the .NET runtime can execute. The dotnet tool handles this translation for you automatically when you run a project. You never manually invoke the compiler yourself - dotnet does it in the background every time you type
dotnet run.
To create your first program, open the terminal in an empty folder and run
dotnet new console. This creates a small starter project including a file called
Program.cs containing one line:
Console.WriteLine("Hello, World!");. Run it with
dotnet run and you will see that text printed in the terminal. That single line -
Console.WriteLine - is the command that writes a line of output for the user to read. Later you will use
Console.ReadLine to accept user input.
The feedback cycle
This loop of
write code, run it, observe the result is the heartbeat of programming. You change something small, run it, and compare the output to what you expected. When the result does not match your expectation, you adjust and try again. Getting comfortable with this fast feedback cycle early makes everything that follows easier: you learn by experiment, not by memorising rules in the abstract. A good exercise is to modify the Hello World message, add a second Console.WriteLine, and run the program after each change to see the effect immediately.
Reading compiler error messages
C# is a
compiled language, which means many mistakes are caught before the program ever runs. When the compiler finds a problem it prints an error message including the file name, line number, and a description of the issue. These messages can look alarming at first, but they are genuinely helpful guidance - they tell you exactly where to look and what the compiler did not understand. The most common beginner errors are missing semicolons at the end of statements, mismatched curly braces, and misspelled method names. None of these are serious - they are just language rules you are still forming habits around.
Watch video: Your First C# Program
Key Insight: The compiler catches many mistakes before the program ever runs. Treat error messages as useful clues - they always include the file name and line number of the problem.
Real-World Example: Change the text inside Console.WriteLine("Hello, World!") to your own name, then run 'dotnet run'. Your message appears instantly. This three-second change-run-observe cycle is the foundation of all programming practice.
Q: What does the C# compiler do?
The compiler translates human-readable C# into an intermediate form the .NET runtime can execute. Without compilation, the computer has no way to run source code directly. 'dotnet run' both compiles and executes the project in one command.
Think about a task you do repeatedly - formatting reports, copying data between systems, sending the same type of email. If you could write a short program to automate that task, what would it need to do?
Variables and Data Types
What a variable is
A
variable is a named container that holds a piece of information your program can use and change. In C#, every variable has a
type that tells the compiler what kind of value it holds. The four types you will use most often are:
int for whole numbers like age or count,
double for numbers with decimal places like a price or percentage,
string for text like a name or email address, and
bool for true-or-false values like whether a user is logged in.
Declaring and assigning variables
You declare a variable by writing the type, a name, and optionally an initial value:
int score = 95; creates an integer named score holding 95. C# is a
statically typed language, meaning a variable's type is fixed at the point of declaration. If you try to assign text to an int variable, the compiler stops you immediately - it never reaches the run stage. This strictness feels limiting at first, but it prevents an entire category of bugs. Storing a product price as an int silently discards the cents. Storing a year as a string makes arithmetic awkward. Choosing the type that matches the real-world meaning of the data keeps your program correct and your intentions clear.
Type inference with var
C# also supports
type inference via the
var keyword: writing
var score = 95; lets the compiler figure out the type from the right side. This is convenient and widely used, but it does not change the static typing guarantee - the type is still fixed at compile time.
var is shorthand for readability, not a way to make variables dynamic. A common misconception is that
var makes C# loosely typed like Python or JavaScript - it does not. The compiler still enforces the type just as strictly; it just saves you typing it twice.
One additional type worth knowing early is
DateTime, for storing dates and times. Working with dates - parsing them, formatting them, calculating differences - is one of the most common real-world tasks and C# has rich built-in support for it.
Watch video: Variables and Data Types
Key Insight: C# is statically typed: a variable's type is fixed when you declare it, and the compiler enforces this before the program runs. This catches an entire category of mistakes early.
Real-World Example: int score = 95; stores a whole number. double price = 29.99; stores a decimal. string name = "Sara"; stores text. bool isActive = true; stores a flag. Using the wrong type - like int for a price - causes errors or silent data loss.
Q: What does "statically typed" mean in the context of C# variables?
Statically typed means the type is fixed at declaration and the compiler enforces it before the program runs. Attempting to assign a string to an int variable is caught immediately - it never becomes a runtime crash.
Where in your own work do you deal with different types of data - dates, amounts, names, true/false flags? How do you currently handle cases where data is the wrong type?
Making Decisions with Conditionals
The if statement
Programs become genuinely useful when they can react differently to different situations. The
if statement runs a block of code only when a condition evaluates to true. Add an
else block to handle the false case, and chain further tests with
else if for three or more distinct outcomes. Each branch contains its own block of statements in curly braces, and the correct block runs while all others are skipped.
Operators for building conditions
Before writing conditions you need the right operators. Comparison operators test relationships:
== checks equality,
!= checks inequality, and
<,
>,
<=,
>= compare sizes. Logical operators combine conditions:
&& means "and" (both sides must be true),
|| means "or" (at least one side must be true), and
! flips a bool from true to false. One critical distinction bears repeating: a single
= assigns a value to a variable, while a double
== compares two values. Confusing them is the single most common beginner bug in C-style languages, and the compiler does not always catch it.
Order matters in chains
In an if/else if chain, conditions are checked from top to bottom and the first true one executes. All remaining branches are skipped, even if they would also be true. This ordering matters: always put the most specific or highest-priority conditions first. A test for
score >= 90 must come before a test for
score >= 70 if grades are to be assigned correctly. If you put the 70 check first, a score of 95 would be graded as B before reaching the A check.
Well-written conditions read almost like plain English. A condition such as
isLoggedIn && hasPermission clearly expresses intent without a comment. Aim for conditions that are small, clearly named, and easy to verify at a glance. If a condition requires a long comment to explain, it is usually a sign that it should be extracted into a named bool variable or a helper method.
Key Insight: A single = assigns a value; double == compares two values. This distinction is one of the most frequent sources of bugs in C-style languages. The compiler often catches it, but not always.
Real-World Example: if (score >= 90) Console.WriteLine("A"); else if (score >= 70) Console.WriteLine("B"); else Console.WriteLine("C"); - conditions checked top to bottom, first match wins.
Q: In an if/else if chain in C#, when does a block of code run?
In a C# if/else if chain, conditions are checked from top to bottom. The moment the first true condition is found, its block executes and all remaining branches are skipped - even if they would also evaluate to true.
Think of a decision you make repeatedly in your work that follows a pattern like "if X then do A, otherwise do B". How would you translate that logic into an if/else structure?
Repeating Work with Loops
Why loops exist
Whenever you find yourself writing the same code multiple times with minor variations, a
loop is the answer. Loops let you express "do this thing N times" or "do this for every item in a collection" without copying and pasting blocks of code. Copying code creates a maintenance problem: if you need to change the logic, you must find and change every copy. A loop means the logic lives in exactly one place.
Three loop types and when to use them
C# offers several loop types. A
for loop is best when you know exactly how many times to repeat: it declares a counter, specifies a condition to keep going, and defines how the counter changes each round. A
while loop repeats as long as a condition stays true - ideal when you do not know the count in advance, such as reading input until the user types "quit." The most beginner-friendly is the
foreach loop: it walks through every item in a collection without you managing any counter at all. You simply write "for each item in this list, do this," and C# handles the iteration. When processing every element of a collection, foreach is almost always the clearest and safest choice.
The infinite loop trap
The most important danger to watch is the
infinite loop - a loop whose condition never becomes false, so it runs forever and freezes the program. This typically happens when the variable the condition depends on is never updated inside the loop body. The fix is always the same: find that variable, and ensure something inside the loop moves it toward the exit condition. A common pattern is using a bool flag that starts true and gets set to false when the desired result is reached.
Loops work hand-in-hand with
collections. An array holds a fixed number of items of the same type, declared once. A
List<T> is a dynamic collection that can grow and shrink as items are added or removed. Both support foreach iteration and both are core tools for working with sets of data - rows from a database, items in an order, names in a report.
Key Insight: An infinite loop runs forever because its condition never turns false. Always check: does something inside the loop move the controlling variable toward the exit condition?
Real-World Example: var names = new List<string> { "Ana", "Ben", "Cara" }; foreach (string name in names) { Console.WriteLine(name); } - prints all three names without a single counter variable.
Q: Which type of loop is best for walking through every item in a collection without managing a counter?
A foreach loop iterates over every item in a collection automatically, with no counter variable to maintain or off-by-one errors to worry about. It is the clearest and most common choice for processing all elements of a list or array.
Imagine you have a spreadsheet with 500 rows of customer data. What tasks would be repetitive enough to benefit from a loop? Think about filtering, formatting, or totalling values.
Organising Code with Methods
What a method is and why it matters
As programs grow, putting everything in one continuous block becomes hard to read and even harder to fix. A
method is a named, reusable block of code that performs one specific task. You define it once and
call it anywhere you need that task done - avoiding duplicated code and giving each operation a clear, searchable name. Think of methods as verbs in your program's vocabulary: AddToCart, SendConfirmationEmail, CalculateDiscount.
Anatomy of a method
A method has a signature that declares its
return type (what value it sends back), its name, and its
parameters (the inputs it receives). A method that calculates and returns a total has a return type of double. A method that prints a message and returns nothing uses the keyword
void. Example:
double CalculateTotal(double price, int quantity) { return price * quantity; } defines a reusable calculation. Calling
CalculateTotal(29.99, 3) receives back 89.97. The caller provides the inputs; the method does the work and optionally hands something back.
The single responsibility principle
Good methods follow two simple rules: they do one clearly-named thing, and they are short enough that you can understand them without scrolling. A method called
SendWelcomeEmail should do exactly that - not also update a database and log an analytics event. Keeping methods small and focused is the most important habit for writing code that other people - and future you - can understand and safely modify.
This principle - breaking a big problem into small, named, independently testable pieces - is the foundation of all professional software design. It scales from a ten-line console app to a hundred-thousand-line enterprise system. Methods are the smallest unit of that decomposition. Learning to write clean, well-named methods early is one of the highest-return investments in your programming education, because the habit applies equally in C#, JavaScript, Python, or any language you learn next.
Key Insight: A good method does one clearly-named thing and is short enough to understand without scrolling. Small, well-named methods are the foundation of readable, maintainable code at every scale.
Real-World Example: int Add(int a, int b) { return a + b; } - defines the method. Add(3, 4) - calls it and gets 7. The name tells you exactly what it does. The body is two words. That is a perfect method.
Q: What does the keyword 'void' mean when used as a method's return type?
A void return type means the method performs its work but does not send any value back to the caller. Methods that compute or look up a result use a specific return type such as int, string, or double instead.
Action step: Look at the most repetitive task in your work and describe it in one sentence starting with a verb - for example "Calculate the total cost". That sentence could become the name of a method. What would its inputs and output be?
Handling Errors Gracefully
Why your program needs a safety net
Even well-written code encounters situations it cannot predict: a file that has been deleted, a number divided by zero, a network connection that drops mid-request. C# handles these situations through
exceptions, which are runtime signals that something unexpected happened. When an exception is thrown, the program stops its normal flow and looks for code that knows how to deal with it. Without any handling in place, an unhandled exception crashes the entire application and shows the user a confusing error screen. The
try/catch block is C#'s built-in mechanism for catching exceptions before they reach that point, letting you respond sensibly rather than crash silently.
The anatomy of try, catch, and finally
A
try block wraps the code that might fail. If an exception is thrown inside it, execution immediately jumps to the matching
catch block, skipping any remaining lines in try. You can catch a broad type like
Exception to handle anything, or catch specific types like
InvalidOperationException (used when a method call is not valid for the current state) or
ArgumentException (used when a method receives a bad argument). Catching specific types lets you respond appropriately: for example, showing a different message for a missing file versus a network timeout. The optional
finally block runs regardless of whether an exception occurred, making it the right place for cleanup code such as closing a database connection or releasing a file handle.
What exceptions are not for
It is important to distinguish between
compile-time errors, which the C# compiler catches before your program runs (such as a typo in a variable name), and
runtime exceptions, which only appear when the code is actually executing. Exceptions carry a real performance cost because the runtime must unwind the call stack to find a handler. For this reason, never use exceptions as a way to control normal program logic: for example, do not throw an exception just to signal that a loop should end, or that a user typed an invalid value in a form. Use
if/else and validation for expected conditions, and reserve try/catch for genuinely unexpected failures you cannot prevent in advance.
Key Insight: Always catch the most specific exception type you expect, and only use try/catch for unexpected failures: not for ordinary decision-making in your code.
Real-World Example: try { int result = int.Parse(userInput); } catch (FormatException ex) { Console.WriteLine($"Not a valid number: {ex.Message}"); } catch (Exception ex) { Console.WriteLine($"Unexpected: {ex.Message}"); } finally { Console.WriteLine("Cleanup always runs here."); }
Q: Which block in a try/catch/finally statement is guaranteed to run whether or not an exception occurs?
The finally block always executes after try and any catch blocks, making it the right place for cleanup code like closing files or database connections: regardless of whether an exception was thrown.
Think about a program you might write that reads data from a file or a database. What could realistically go wrong at runtime that a try/catch would handle? How would you decide between using an if/else check versus a try/catch for that scenario?
Module 3: Object-Oriented C#
Classes, Objects, and Encapsulation
Move beyond single scripts and learn to model real-world things as objects - the way most serious C# software is designed and built.
Learning Objectives - Explain what a class and an object are and how they relate
- Define properties and methods that give objects data and behaviour
- Apply encapsulation to protect an object's internal state
- Use constructors to ensure objects begin in a valid state
- Recognise when inheritance makes sense and when it does not
What You'll Learn - Classes as blueprints and objects as instances
- Properties with get and set accessors
- Methods that operate on an object's own data
- Encapsulation and access modifiers: public and private
- Constructors and the new keyword
- Inheritance, base classes, and derived classes
- When object-oriented design makes code clearer - and when it does not
Classes and Objects
The blueprint and the instance
Object-oriented programming organises code around real-world things rather than sequences of instructions. A
class is a blueprint that describes what something is and what it can do. An
object (also called an instance) is a specific thing created from that blueprint. The class BankAccount describes the concept of an account: it has a balance, an owner, and the ability to deposit and withdraw. Every individual account in your system is a separate object created from that same class blueprint.
Creating and using objects
You create an object with the
new keyword:
var account = new BankAccount();. This allocates memory for the object and calls its constructor. Once you have the object, you access its data and behaviour through
dot notation:
account.Balance reads the balance property, and
account.Deposit(500) calls a method on it. The dot separates the object from what you are accessing.
Why this matters for application design
Grouping related data and behaviour into a class gives your code structure that mirrors the real problem you are solving. A product catalogue might have classes for Product, Category, and Order. A hospital system might have Patient, Appointment, and Doctor. When you read code that uses well-named classes, it reads almost like a description of the business domain rather than a list of raw operations. This alignment between code structure and business concepts is one of the most powerful benefits of object-oriented design.
A class can also contain other classes as properties. An Order might contain a Customer and a list of OrderItem objects. This composition lets you model complex real-world relationships naturally and navigate them with clean, readable dot notation throughout your program. Each class owns its own data and behaviour, which makes it easy to reason about, test, and change any one class without inadvertently breaking others.
Watch video: Classes and Objects
Key Insight: A class is the blueprint; an object is the instance. You can create many objects from one class, each with their own data but sharing the same structure and behaviour.
Real-World Example: var cat1 = new Cat(); var cat2 = new Cat(); - creates two separate Cat objects. Changing cat1.Name does not affect cat2.Name. Each object has its own copy of the data defined in the class.
Q: What is the relationship between a class and an object in C#?
A class is the blueprint or template that defines structure and behaviour. An object is a specific instance created from that class using the new keyword. Many objects can be created from one class, each holding their own data.
Think about your work or industry. What things do you deal with that could be modelled as classes? What data and actions would each have?
Properties and Methods
Properties: the data an object holds
A class defines two kinds of members.
Properties hold the data that belongs to each object - the name, balance, status, or any other attribute. In C# a property typically has a
get accessor that reads the value and a
set accessor that writes it. The shorthand
public string Name { get; set; } creates a full read-write property with minimal code. You can make a property read-only by using only a get accessor, or you can add validation logic to the setter to check values before storing them.
Methods: the behaviour an object has
Methods define what an object can do - the actions it performs, usually using its own properties. A key distinction from standalone functions is that a class method implicitly has access to all the object's own data. A Withdraw method on a BankAccount class can read and update the balance directly without needing it as a parameter, because the method is called on a specific account object and already knows whose data to use.
Designing the public interface
Together, properties and methods form the
interface of a class - the contract of what callers can do with it. Marking members
public makes them accessible from outside the class. Marking them
private hides them as implementation details. A well-designed class exposes only what callers need and hides everything else.
When naming properties and methods, use clear intention-revealing names.
GetDiscountedPrice() is far better than
Calc(). Code is read far more often than it is written, and names that communicate intent save enormous amounts of reading and guessing time as a codebase grows over months and years. Every name you choose is a tiny piece of documentation that either helps or misleads the next reader.
Key Insight: Properties hold data; methods define behaviour. Together they form the contract of a class - what it knows and what it can do.
Real-World Example: account.Balance gets the value (property read). account.Deposit(200) performs an action (method call). The method updates the balance internally. The caller does not need to know how.
Q: What is the main difference between a property and a method in a C# class?
A property represents data that an object holds, accessed with get and set accessors. A method defines an action the object can perform. Both are members of a class, but they serve different purposes.
For a class that represents a product in an online store, what properties would it have? What methods? Think about what data it stores and what operations you would perform on it.
Encapsulation and Access Modifiers
What encapsulation means
Encapsulation is the practice of hiding an object's internal details and exposing only what callers genuinely need. It is achieved through
access modifiers: the keywords
public and
private that control which parts of a class are visible from outside. A
public member can be accessed by any code anywhere. A
private member can only be accessed by code inside the same class.
Protecting state with private fields
The most common pattern is to make the raw data field private and expose it through a property with controlled access. A BankAccount class might have a
private double _balance field that only the class itself can write to directly. The public Balance property has only a get accessor - callers can read the balance but cannot set it directly. The only way to change the balance is through the Deposit or Withdraw methods, which can enforce business rules: no negative deposits, no withdrawals that exceed the available balance.
Why this matters in practice
Encapsulation is not just academic. Without it, any part of your program can set any property to any value - including invalid ones. A customer age could be set to -5. A price could become zero without any business rule checking. Bugs like these are notoriously hard to track down because the invalid value can be written in one place and cause a crash somewhere completely different, long after the original assignment.
When internal details are hidden, you can also change how a class works internally without breaking any code that uses it. If you decide to store a person's name as two separate fields (first name and last name) instead of one combined string, you change only the class internals. All callers still use the same public Name property and notice no difference at all. This ability to change implementations without breaking callers is one of the main reasons encapsulation is central to sustainable software design.
Key Insight: Encapsulation hides internal details. Private fields and controlled property access prevent external code from putting objects into invalid states.
Real-World Example: A BankAccount with private _balance and a public Withdraw method: the method checks that the amount does not exceed the balance before reducing it. No caller can bypass this rule by setting the field directly.
Q: What does the private access modifier do in C#?
The private modifier restricts access to the same class only. External code - including subclasses - cannot read or write private members directly. This is the foundation of encapsulation.
In your work, what are examples of information that should be read-only from the outside but changeable internally through controlled processes? How does that map to encapsulation?
Constructors and the new Keyword
What a constructor does
When you write
new BankAccount(), C# calls a special method called the
constructor. A constructor has the same name as its class, no return type, and runs automatically the moment an object is created. Its job is to put the new object into a valid starting state - setting initial property values, validating required inputs, and preparing any resources the object needs before it is used by other code.
Parameterless vs parameterised constructors
A constructor can have no parameters (a
default constructor), in which case calling
new ClassName() creates an object with sensible defaults. Or it can take parameters that the caller must provide, ensuring the object is never created without required data. A Customer class might require a name and email:
new Customer("Sara", "sara@example.com"). Inside the constructor, these values are assigned to properties. If validation fails - empty name, invalid email format - the constructor can throw an exception to prevent the invalid object from being created at all.
Object initialiser syntax
C# also offers
object initialisers as an alternative:
new Customer { Name = "Sara", Email = "sara@example.com" }. This sets properties after the default constructor runs. It is more flexible but provides less control over validation since there is no single place to check all properties together. Which approach to use depends on how strictly you need to enforce valid state at creation time.
The deeper principle is: an object should
always be in a valid state. A constructor that allows invalid data defeats the purpose of encapsulation. Well-designed constructors are the first line of defence against objects that are half-initialised or internally inconsistent, and catching problems at object creation is far cheaper than hunting them down at runtime hours or days later.
Key Insight: A constructor runs automatically when an object is created with new. It is responsible for ensuring the object starts in a valid, fully initialised state.
Real-World Example: new Customer("Sara", "sara@example.com") - the constructor receives both required values, validates them, and assigns them to properties. The object is ready to use immediately after new returns.
Q: What is the primary purpose of a constructor in a C# class?
A constructor runs automatically when new is called and is responsible for initialising the object to a valid starting state. It can validate inputs, set default values, and prepare any resources the object needs.
If you were designing a class for a bank account, what data would you require at creation time (in the constructor)? What data could be set later? Why?
Inheritance: Sharing Behaviour
What inheritance enables
Inheritance lets one class (the derived class or subclass) inherit all the properties and methods of another class (the base class or parent class), then add or change what it needs. In C# you declare inheritance with a colon:
class SavingsAccount : BankAccount means SavingsAccount inherits everything from BankAccount and extends it. The derived class gains all public and protected members of the base class and can add its own on top.
Overriding behaviour with virtual and override
Sometimes the derived class needs to replace a base class behaviour rather than just add to it. Marking a method as
virtual in the base class signals that subclasses may override it. Using
override in the subclass replaces that method's implementation. A base Shape class might have a virtual CalculateArea() method. A Circle subclass overrides it with the circle area formula; a Rectangle subclass overrides it with width times height. Code that works with a Shape can call CalculateArea() on any shape and get the right result for that specific type - this is called
polymorphism.
When not to use inheritance
Inheritance is powerful but easily overused. The right question to ask before inheriting is: is this subclass truly a
type of the parent? A SavingsAccount is truly a type of BankAccount, so inheritance fits well. A Car being made of an Engine does not mean Car should inherit from Engine - it means Car should
contain an Engine as a property. This "has-a vs is-a" distinction separates inheritance (is-a) from
composition (has-a). In modern C# design, composition is preferred over inheritance in most cases because it creates fewer hidden dependencies and is easier to change over time without unintended side effects cascading through the class hierarchy.
Watch video: Inheritance: Sharing Behaviour
Key Insight: Inheritance expresses "is-a" relationships: a SavingsAccount is-a BankAccount. Prefer composition (has-a) when the relationship is about containing or using something, not being a type of it.
Real-World Example: class SavingsAccount : BankAccount { public double InterestRate { get; set; } } - SavingsAccount inherits all BankAccount members and adds an InterestRate property specific to savings accounts.
Q: Which keyword in C# is used to declare that one class inherits from another?
In C#, inheritance is declared with a colon after the class name: class Derived : Base. The derived class inherits all public and protected members of the base class and can extend or override them.
Do you agree that composition (has-a) is usually better than inheritance (is-a)? Think of a design scenario from your own experience where using inheritance seemed right but caused problems later.
Module 4: MS SQL Server
Relational databases and SQL queries
Design relational databases, write SQL queries to create, read, update, and delete records, and use joins to combine data from multiple tables.
Learning Objectives - Explain what a relational database is and how tables relate to each other
- Write SQL queries using SELECT, INSERT, UPDATE, and DELETE
- Use WHERE, ORDER BY, and GROUP BY to filter and sort results
- Join multiple tables to retrieve combined data
- Create tables with appropriate data types and constraints
What You'll Learn - What SQL Server is and how to connect with SSMS
- Tables, rows, columns, and primary keys
- Writing SELECT queries with WHERE and ORDER BY
- INSERT, UPDATE, and DELETE statements
- INNER JOIN, LEFT JOIN, and relationships
- Aggregate functions: COUNT, SUM, AVG, MIN, MAX
- Creating tables with CREATE TABLE and data types
- Foreign keys and referential integrity
What Is a Relational Database?
A relational database stores data in tables - structured grids where every row is a record and every column is a field. The word "relational" refers to the fact that tables can relate to each other through shared identifiers. Instead of repeating a customer's name in every order record, you store the customer once and reference them by a number from the orders table. This saves space and makes updates instant: change the customer's email in one place and every order automatically reflects it.
Why SQL Server?
Microsoft SQL Server is one of the most widely used relational database systems in enterprise software. It runs on Windows and Linux, integrates naturally with the .NET ecosystem, and includes SQL Server Management Studio (SSMS) - a graphical tool that lets you browse tables, write queries, and visualise database structure. For full-stack development with C# on the back-end, SQL Server is the natural choice.
Connecting and exploring
When you open SSMS and connect to your local instance (usually called "." or "localhost"), you see a tree of databases. Each database contains Tables, Views, and Stored Procedures. Right-clicking a table and selecting "Select Top 1000 Rows" runs a quick SELECT query and shows the data in a grid. This is the fastest way to explore an unfamiliar database.
Tables, primary keys, and uniqueness
Every table should have a primary key - a column (or combination of columns) that uniquely identifies each row. SQL Server typically uses an INT IDENTITY column that auto-increments: when you insert a new row, the database assigns the next number automatically. This ID becomes the reference point that other tables use. A Customers table might have CustomerID as its primary key, and an Orders table would store CustomerID as a foreign key linking back to the customer.
The discipline of designing tables correctly - avoiding duplication and ensuring clean relationships - is called normalisation. A well-normalised database is easier to query, easier to maintain, and less prone to inconsistencies when data changes. The basic rule of thumb: each piece of information lives in exactly one place.
Watch video: What Is a Relational Database?
Key Insight: A relational database organises data into tables that relate to each other via keys - eliminating duplication and making updates instant across the entire dataset.
Q: What is the purpose of a primary key in a database table?
A primary key uniquely identifies each row. No two rows can share the same primary key value, which ensures every record is distinguishable and can be reliably referenced by other tables via foreign keys.
Think of a real-world business scenario - a shop, a school, a clinic. What tables would you need and how would they relate to each other?
SELECT: Reading Data
The SELECT statement is the workhorse of SQL. It retrieves rows from one or more tables based on conditions you specify. The simplest form is SELECT * FROM TableName, which returns every column and every row. In practice, you almost always narrow down the results - either by choosing specific columns or by adding a WHERE clause to filter rows.
Choosing columns and filtering rows
To retrieve specific columns, list them after SELECT: SELECT FirstName, LastName, Email FROM Customers. The WHERE clause filters rows: SELECT * FROM Orders WHERE Status = 'Pending' returns only pending orders. Comparison operators work as expected: =, <>, <, >, <=, >=. You can combine conditions with AND and OR: WHERE Status = 'Pending' AND TotalAmount > 500.
Sorting and limiting results
ORDER BY controls the sequence: SELECT * FROM Products ORDER BY Price DESC sorts by price from highest to lowest. TOP limits the number of rows returned: SELECT TOP 10 * FROM Products ORDER BY Price DESC gives you the ten most expensive products. DISTINCT eliminates duplicates: SELECT DISTINCT City FROM Customers returns each city only once regardless of how many customers share it.
Pattern matching and ranges
LIKE matches partial strings: WHERE Email LIKE '%@gmail.com' finds all Gmail addresses. The percent sign (%) is a wildcard meaning "any characters". BETWEEN tests ranges: WHERE Price BETWEEN 10 AND 50. IN checks membership in a list: WHERE Status IN ('Pending', 'Processing') matches either value.
Aliases and readability
AS creates readable column aliases: SELECT FirstName + ' ' + LastName AS FullName FROM Customers. The result column is labelled FullName instead of an expression. Aliases are also used for tables in joins, keeping the query concise: FROM Customers AS c. Writing clear, well-formatted SQL with consistent indentation makes queries much easier to read and debug when something goes wrong.
Watch video: SELECT: Reading Data
Real-World Example: SELECT TOP 5 ProductName, Price FROM Products WHERE CategoryID = 3 ORDER BY Price DESC: returns the five most expensive products in category 3.
Q: What does the WHERE clause do in a SELECT statement?
The WHERE clause filters which rows are included in the result set. Only rows where the condition is true are returned. ORDER BY sorts results, TOP limits the count, and GROUP BY groups rows for aggregates.
Write a SQL query in your head for data you deal with at work - what would you want to SELECT and what WHERE condition would make it useful?
INSERT, UPDATE, DELETE
Reading data is only half of SQL. The other half is making changes: adding new records, modifying existing ones, and removing records you no longer need. These three statements - INSERT, UPDATE, and DELETE - together with SELECT form the foundation of every data-driven application.
INSERT: Adding new rows
INSERT INTO TableName (Column1, Column2) VALUES (Value1, Value2) adds a row. You list the column names in one set of parentheses and the matching values in another. You can omit columns that have defaults or auto-increment. To retrieve the ID assigned to the new row immediately after inserting, use OUTPUT INSERTED.ID in the INSERT statement - this is essential when you need to reference the new row in a related table straight away.
UPDATE: Modifying existing rows
UPDATE TableName SET Column1 = Value1 WHERE Condition changes specific columns in matching rows. The WHERE clause is critical - without it, every row in the table gets updated. A common pattern is updating a status: UPDATE Orders SET Status = 'Shipped' WHERE OrderID = 42. You can update multiple columns in one statement: SET Status = 'Shipped', ShippedDate = GETDATE() updates two fields simultaneously. GETDATE() is a SQL Server function that returns the current date and time.
DELETE: Removing rows
DELETE FROM TableName WHERE Condition removes matching rows permanently. Like UPDATE, a missing WHERE clause deletes everything. Before running a DELETE, run the equivalent SELECT first to confirm you are targeting the right rows. If you need to empty a table completely and reset identity counters, TRUNCATE TABLE is faster than DELETE with no WHERE, though TRUNCATE cannot be rolled back easily.
Transactions for safety
Wrap critical operations in a transaction: BEGIN TRANSACTION; perform your changes; if everything looks right, COMMIT; otherwise ROLLBACK to undo. Transactions are essential when multiple related tables must be updated together - for example, inserting an order AND its line items should succeed or fail as a unit, never leaving partial data.
Key Insight: Always include a WHERE clause on UPDATE and DELETE - omitting it will affect every row in the table.
Q: What happens if you run UPDATE Orders SET Status = 'Shipped' without a WHERE clause?
Without a WHERE clause, UPDATE affects every row in the table. All orders would have their Status changed to Shipped regardless of their actual state. This is a common and destructive mistake - always test with a SELECT first.
What would be the consequences if a bug in your code ran a DELETE or UPDATE without a WHERE clause on a production database? How would you prevent this?
Joining Tables
The real power of a relational database comes from joining tables. A join combines rows from two or more tables based on a related column - typically a foreign key matching a primary key. Instead of storing redundant data, you store references and assemble complete records at query time.
INNER JOIN: Only matching rows
INNER JOIN returns only rows where the join condition is met in both tables. SELECT c.Name, o.OrderDate FROM Customers c INNER JOIN Orders o ON c.CustomerID = o.CustomerID returns all orders with the matching customer name. Customers who have never placed an order do not appear. Orders without a valid customer (data integrity issue) do not appear either.
LEFT JOIN: All rows from the left
LEFT JOIN (also called LEFT OUTER JOIN) returns all rows from the left table even when there is no match in the right table. Columns from the right table are NULL for unmatched rows. SELECT c.Name, COUNT(o.OrderID) AS OrderCount FROM Customers c LEFT JOIN Orders o ON c.CustomerID = o.CustomerID GROUP BY c.Name counts orders per customer and includes customers with zero orders (they show 0, not NULL, because COUNT of NULL is 0).
Joining three or more tables
You can chain multiple joins. A typical e-commerce query might join Customers to Orders to OrderItems to Products: SELECT c.Name, p.ProductName, oi.Quantity FROM Customers c INNER JOIN Orders o ON c.CustomerID = o.CustomerID INNER JOIN OrderItems oi ON o.OrderID = oi.OrderID INNER JOIN Products p ON oi.ProductID = p.ProductID. Each JOIN adds another table to the result set by matching on a shared key.
Aggregate functions in joins
Aggregate functions summarise groups of rows: COUNT(*) counts rows, SUM(Amount) totals a column, AVG(Price) computes the average, MIN and MAX find extremes. GROUP BY defines the grouping: SELECT CustomerID, SUM(TotalAmount) AS TotalSpent FROM Orders GROUP BY CustomerID. HAVING filters groups (like WHERE but for aggregated results): HAVING SUM(TotalAmount) > 1000 keeps only high-value customers.
Real-World Example: SELECT c.Name, COUNT(o.OrderID) AS TotalOrders, SUM(o.Amount) AS TotalSpent FROM Customers c LEFT JOIN Orders o ON c.CustomerID = o.CustomerID GROUP BY c.CustomerID, c.Name ORDER BY TotalSpent DESC
Q: What is the key difference between INNER JOIN and LEFT JOIN?
INNER JOIN filters to only matching rows in both tables. LEFT JOIN returns every row from the left table and fills in NULLs for columns from the right table where no match exists. Use LEFT JOIN when you need to include records that have no related data.
In a database you work with or can imagine, which two tables would you join most often and what question would that join answer?
Creating Tables and Schema Design
Writing queries is only possible once you have tables to query. CREATE TABLE defines a table's structure: column names, data types, constraints, and relationships. Good schema design before you write a single line of application code saves enormous refactoring later.
Data types in SQL Server
The most common types: INT for whole numbers; DECIMAL(10,2) for money (10 total digits, 2 after the decimal); NVARCHAR(100) for text up to 100 Unicode characters (use NVARCHAR not VARCHAR for multilingual support); BIT for true/false; DATETIME2 for date and time; UNIQUEIDENTIFIER for GUIDs. Choosing the right type ensures data integrity - you cannot accidentally store "hello" in an INT column.
Constraints
PRIMARY KEY ensures uniqueness and disallows NULL. NOT NULL prevents empty values in required columns. UNIQUE ensures no two rows share the same value in a column (useful for email addresses). DEFAULT sets an automatic value when none is provided: DEFAULT GETDATE() on a CreatedAt column stamps every new row with the current time. CHECK enforces a business rule: CHECK (Price > 0) prevents negative prices.
Foreign keys and referential integrity
FOREIGN KEY links a column to the primary key of another table. FOREIGN KEY (CustomerID) REFERENCES Customers(CustomerID) means the Orders table can only store CustomerID values that exist in the Customers table. The database rejects any insert or update that would create an orphaned reference. ON DELETE CASCADE automatically deletes child rows when the parent is deleted.
Sample CREATE TABLE
CREATE TABLE Orders (OrderID INT IDENTITY PRIMARY KEY, CustomerID INT NOT NULL, OrderDate DATETIME2 DEFAULT GETDATE(), TotalAmount DECIMAL(10,2) NOT NULL, Status NVARCHAR(20) DEFAULT 'Pending', FOREIGN KEY (CustomerID) REFERENCES Customers(CustomerID)). This single statement enforces types, defaults, NOT NULL constraints, and referential integrity all at once. ALTER TABLE lets you add or modify columns after creation, though changing types on existing columns with data requires care.
Key Insight: Design your database schema before writing application code. Fixing structural mistakes after data exists is far harder than getting the design right upfront.
Q: What does a FOREIGN KEY constraint enforce?
A foreign key constraint ensures that a value in one table exists as a primary key in another table. This enforces referential integrity - you cannot create an order referencing a CustomerID that does not exist in the Customers table.
If you were designing a database for a small gym membership system, what tables would you create and what foreign key relationships would connect them?
Module 5: C# Web API
Building RESTful Endpoints with ASP.NET Core
Learn how ASP.NET Core Web API turns C# methods into HTTP endpoints: creating the server-side backbone that connects your React front-end to SQL Server data.
Learning Objectives - Explain what a REST API is and how HTTP verbs map to data operations
- Create an ASP.NET Core 10 Web API project and explore its default structure
- Build controllers with routes that handle GET, POST, PUT, and DELETE requests
- Return and receive JSON data using built-in serialisation and model binding
- Describe the middleware pipeline and how requests flow through an ASP.NET Core app
What You'll Learn - REST principles and HTTP verbs
- JSON as the data exchange format
- HTTP status codes (200, 201, 400, 404, 500)
- Creating a Web API project with the dotnet CLI
- Controllers, routing, and action methods
- Returning IActionResult responses
- Model binding with [FromBody], [FromRoute], [FromQuery]
- Middleware and the ASP.NET Core request pipeline
The Project We Will Build
The running example: a task board application
Throughout this module every concept is grounded in a concrete project — a task board application where users organise work into
boards, each containing multiple
lists (such as "To Do", "In Progress", "Done"), with each list holding individual
cards representing tasks. You will see this data referenced in every example, knowledge check, and reflection prompt from here on.
Why a task board?
A task board has just enough complexity to make API design decisions meaningful, without becoming overwhelming. It has multiple resource types that relate to each other — boards own lists, lists own cards — so you get real practice designing routes, handling nested resources, and thinking about which status code fits which situation. When you encounter a question like "what should happen when a card is requested but its list no longer exists?", it is not a hypothetical: it is a decision you would face building this.
The data model at a glance
The application has three resource levels. A
Board has an id, title, and owner. A
List belongs to a board and has a position. A
Card belongs to a list and has a title, description, and position. Every API endpoint in this module maps to one of these three resources. Understanding this hierarchy now means the endpoint designs, route templates, and JSON responses will feel natural rather than arbitrary as you work through the sections ahead.
The Chello companion repository
The working code for all of this lives in the Chello companion repository at
github.com/rickysoo/Chello. Lessons 15–20 in the repo correspond directly to this module. You can open those lesson folders alongside each section to see the real C# code in context.
Key Insight: Boards contain lists; lists contain cards. This three-level hierarchy is the data model behind every endpoint you will build in this module.
Real-World Example: GET /api/boards → all boards. GET /api/boards/3/lists → all lists inside board 3. GET /api/lists/7/cards → all cards inside list 7. The URL structure mirrors the data hierarchy.
Q: What is the data hierarchy in the task board application used throughout this module?
A Board contains Lists, and each List contains Cards. This hierarchy drives the API design: endpoints that target cards must know which list they belong to, and list endpoints reference their parent board.
Before reading on, open github.com/rickysoo/Chello and browse the Lesson15 folder. You do not need to understand the code yet — just getting a feel for what a real Web API project looks like helps the concepts in this module land faster.
What is a REST API?
The idea behind an API
A
REST API (Representational State Transfer Application Programming Interface) is a way for two separate programs to talk to each other over the internet using the same HTTP protocol that browsers use to load web pages. In the Chello application, your React front-end and your SQL Server database are two separate things: the API sits in the middle, receiving requests from React, doing work against the database, and sending results back. This separation of concerns means your front-end does not need to know anything about SQL, and your database does not need to know anything about the browser. Each side only speaks to the API, which enforces clear rules about what operations are allowed and what data looks like.
Resources and HTTP verbs
REST thinks about data as
resources: things like boards, lists, and cards. Each resource has a URL, called an
endpoint. The operation you want to perform is communicated through an
HTTP verb:
GET retrieves data without changing anything,
POST creates a new resource,
PUT replaces an existing resource entirely,
PATCH makes a partial update, and
DELETE removes a resource. A single URL like
/api/cards can therefore support multiple operations depending on which verb is used. This design keeps URLs simple and predictable: a pattern called a
RESTful interface.
JSON and status codes
REST APIs typically exchange data as
JSON (JavaScript Object Notation), a lightweight text format that both C# and JavaScript understand natively. A card might look like
{"id": 1, "title": "Design login screen", "listId": 2}. Alongside the data, every HTTP response carries a
status code that tells the caller what happened:
200 OK means success,
201 Created confirms a new resource was saved,
400 Bad Request means the caller sent invalid data,
404 Not Found means the requested resource does not exist, and
500 Internal Server Error signals an unexpected problem on the server. Reading status codes correctly is essential for building a front-end that handles all outcomes gracefully rather than assuming every request succeeds.
Watch video: What is a REST API?
Key Insight: A REST API lets your React front-end and SQL Server database stay completely separate: each talks only to the API, which acts as the rules-enforcing intermediary between them.
Real-World Example: GET /api/cards: retrieve all cards. GET /api/cards/7: retrieve card 7. POST /api/cards: create a new card. PUT /api/cards/7: replace card 7. DELETE /api/cards/7: delete card 7.
Q: You want to retrieve a single board by its ID without modifying any data. Which HTTP verb should you use?
GET is the correct verb for read-only retrieval. It signals that the operation is safe and idempotent: calling it multiple times has no side effects. POST creates, PUT updates, and DELETE removes resources.
Think about the Chello app data: boards, lists, and cards. For each resource, what endpoints would you need? Which HTTP verbs would each support, and what would a 404 response mean in practice?
Creating an ASP.NET Core Web API Project
Scaffolding a new project
The fastest way to start an ASP.NET Core 10 Web API is a single terminal command:
dotnet new webapi -n ChelloApi. This scaffolds a complete working project: a
.csproj file, a
Program.cs entry point, and a sample
WeatherForecast controller to demonstrate the pattern. Running
dotnet run inside the new folder starts a local development server, typically on
https://localhost:5001. ASP.NET Core 10 is the current LTS release and uses a streamlined
Program.cs that looks nothing like the verbose startup classes from older versions: most configuration now fits in a single concise file using the
WebApplication builder pattern.
Swagger UI and testing your endpoints
In development mode, ASP.NET Core automatically serves
Swagger UI at
/swagger. This is an interactive web page that lists every endpoint in your API, lets you fill in parameters, send real requests, and inspect the JSON responses: all without needing any additional tools. It is generated from
OpenAPI metadata that the framework extracts from your controllers automatically. For testing individual HTTP calls from the terminal, VS Code supports
.http files: plain text files where you write raw HTTP requests and run them with a button click, keeping a readable history of your test calls alongside your source code.
Minimal APIs vs Controllers
ASP.NET Core supports two styles for defining endpoints.
Minimal APIs let you register routes directly in
Program.cs with one-line lambda expressions: ideal for small services where speed of setup matters.
Controller-based APIs group related endpoints inside a class that inherits from
ControllerBase, with attributes on each method that declare the route and verb. For a project like Chello you will use the controller style because it scales more clearly as the number of endpoints grows and keeps routing logic out of
Program.cs. Understanding the distinction matters because you will encounter both styles in documentation and tutorials.
Watch video: Creating an ASP.NET Core Web API Project
Key Insight: Run dotnet new webapi, then visit /swagger in development: you get a live, clickable API explorer generated automatically from your code with zero extra setup.
Real-World Example: dotnet new webapi -n ChelloApi → dotnet run → visit https://localhost:5001/swagger. The Swagger UI lists all endpoints and lets you send test requests from the browser instantly.
Q: Where does Swagger UI appear by default when you run an ASP.NET Core Web API in development mode?
Swagger UI is served at /swagger in development mode. It is generated automatically from OpenAPI metadata that ASP.NET Core extracts from your controllers, giving you an interactive way to explore and test every endpoint without any additional tools.
The default template includes a WeatherForecast controller as a demonstration. Before you start hands-on lessons, look at that generated controller: can you identify the [Route] attribute, the [HttpGet] attribute, and the return type?
Controllers and Routing
What a controller does
In the controller pattern, a
controller is a C# class that groups all the endpoints for one resource. A
CardsController holds every action related to cards: fetching them, creating them, updating them, deleting them. The class is decorated with two attributes:
[ApiController] enables automatic model validation, automatic 400 responses for invalid input, and other API-friendly conventions; and
[Route("api/[controller]")] sets the base URL for every action in the class, where
[controller] is replaced with the class name minus the word "Controller", so
CardsController becomes
/api/cards.
Action methods and HTTP verb attributes
Each public method in a controller is an
action method that maps to an endpoint. You declare which HTTP verb it responds to using attributes:
[HttpGet],
[HttpPost],
[HttpPut], and
[HttpDelete]. These attributes can also include a route template: for example
[HttpGet("{id}")] makes the action respond to
GET /api/cards/7, and the
id in the URL is automatically extracted and passed as a parameter to the method. This is called a
route parameter. You can define multiple actions with the same verb if their route templates are different, letting a single controller handle both
GET /api/cards (all cards) and
GET /api/cards/7 (one card) cleanly.
Return types and IActionResult
Action methods return
IActionResult, an interface that represents any HTTP response. ASP.NET Core provides helper methods on
ControllerBase that create these responses:
Ok(data) sends a 200 with a JSON body,
NotFound() sends a 404,
BadRequest(message) sends a 400, and
Created(uri, data) sends a 201 with a Location header pointing to the new resource. Using
IActionResult rather than returning a raw object gives you full control over the status code: critical because a front-end that always receives 200 cannot tell the difference between a successful fetch and a silent failure.
Key Insight: [ApiController] and [Route] on the class plus [HttpGet("{id}")] on the method is all you need to wire up a fully functional, correctly routed API endpoint in ASP.NET Core.
Real-World Example: [ApiController] [Route("api/[controller]")] public class CardsController : ControllerBase { [HttpGet] public IActionResult GetAll() { return Ok(cards); } [HttpGet("{id}")] public IActionResult GetById(int id) { return id > 0 ? Ok(card) : NotFound(); } }
Q: You have a CardsController with [Route("api/[controller]")]. What URL does an action marked [HttpGet("{id}")] respond to?
The [controller] token is replaced with the class name minus "Controller", so CardsController becomes "cards". The action's own [HttpGet("{id}")] appends the {id} segment, giving /api/cards/{id}: for example /api/cards/7.
For the Chello app, would you put list endpoints and card endpoints in one controller or two? How would you design the routes so that fetching all cards inside a specific list is clearly expressed using route attributes?
Returning and Receiving Data
Sending data back to the caller
When an action method calls
Ok(someObject), ASP.NET Core automatically
serialises the C# object to JSON and writes it into the response body: you do not need to call any JSON library yourself. By default, property names are written in
camelCase (so a C# property called
BoardId becomes
"boardId" in the JSON), which matches what JavaScript expects. The reverse process: reading JSON from a request body and turning it into a C# object: is called
deserialisation and happens equally automatically. You create a
model class (a plain C# class with properties) that matches the shape of the incoming JSON, and ASP.NET Core populates it for you.
Model binding: where does the data come from?
When an action method has parameters, ASP.NET Core uses
model binding to figure out where to pull the values from. The
[FromBody] attribute tells the framework to read the parameter from the JSON request body: used for POST and PUT requests that send a complete object.
[FromRoute] reads the value from the URL segment, equivalent to what happens automatically with route parameters like
{id}.
[FromQuery] reads from the URL query string: for example
GET /api/cards?listId=3 would bind the
listId=3 part. Being explicit with these attributes makes your intentions clear to other developers.
Validating incoming data
Never trust data that arrives from a client. ASP.NET Core's
data annotations let you add validation rules directly to your model class properties:
[Required] prevents null or empty values,
[Range(1, 100)] enforces numeric bounds,
[MaxLength(200)] limits string length, and
[EmailAddress] validates email format. When the
[ApiController] attribute is present on the controller, ASP.NET Core checks these rules automatically before your action method even runs: if anything fails, it returns a
400 Bad Request response with a detailed error object describing exactly which fields are invalid.
Key Insight: ASP.NET Core serialises and deserialises JSON for you automatically: just return a C# object from Ok() and accept a model class as a [FromBody] parameter.
Real-World Example: public class CreateCardRequest { [Required] [MaxLength(200)] public string Title { get; set; } = ""; [Range(1, int.MaxValue)] public int ListId { get; set; } }: [ApiController] auto-returns 400 if validation fails.
Q: Which attribute should you use on a model property to ensure the API automatically rejects a request where that property is missing or empty?
[Required] is a data annotation that marks a property as mandatory. When [ApiController] is present, ASP.NET Core validates all incoming models against these annotations before the action runs and automatically returns a 400 Bad Request if any required fields are missing.
Think about the Card model for a Chello-like app: title, description, list it belongs to, position. Which properties would you mark as [Required]? Would you add [Range] or [MaxLength] constraints?
Middleware and the Request Pipeline
What middleware is
Every HTTP request that arrives at an ASP.NET Core application passes through a sequence of components before reaching your controller, and passes back through them on the way out. Each component in this sequence is called
middleware. Middleware can inspect or modify the request, decide whether to pass it further down the chain, perform work before and after the next component runs, and optionally short-circuit the pipeline entirely by sending a response without forwarding the request. Built-in examples include the HTTPS redirection middleware (which automatically upgrades HTTP requests to HTTPS), the authentication middleware (which reads tokens and establishes the caller's identity), and the routing middleware (which matches the URL to a controller action).
The pipeline order matters
In
Program.cs, middleware is registered by calling
app.Use... methods in a specific order: and that order is not arbitrary.
Authentication must run before
authorisation because you cannot check what a caller is allowed to do until you know who they are.
Routing must run before the endpoint is executed.
HTTPS redirection should run early so that unencrypted requests are upgraded before any other processing touches them. Each
app.Use... call adds a layer to the pipeline, and the layers execute in the order they are added. A request flows inward through each layer in sequence, hits the controller action at the centre, and the response flows back out in reverse order.
Authentication, authorisation, and logging
Authentication answers "who are you?": typically by validating a
JWT bearer token in the
Authorization HTTP header. Once identity is established,
authorisation answers "are you allowed to do this?" using the
[Authorize] attribute on controllers or action methods. Adding
[Authorize] to the
CardsController means every endpoint in that controller requires a valid token: unauthenticated requests receive a
401 Unauthorised response automatically. ASP.NET Core ships with built-in structured logging via
ILogger<T>, which you can inject directly into any controller without additional libraries to record what endpoints were called, by whom, and how long they took.
Watch video: Middleware and the Request Pipeline
Key Insight: Middleware runs in the exact order you register it in Program.cs: authentication must come before authorisation, and routing before endpoint execution, or requests will be processed incorrectly.
Real-World Example: app.UseHttpsRedirection(); app.UseAuthentication(); // 1. who are you? app.UseAuthorization(); // 2. are you allowed? app.MapControllers(); // 3. route to action: order is mandatory.
Q: In the ASP.NET Core middleware pipeline, why must UseAuthentication() be called before UseAuthorization()?
Authentication identifies the caller by reading and validating their token. Authorisation then checks whether that identified caller has permission for the requested resource. If authorisation ran first, there would be no established identity to check permissions against.
Some Chello endpoints should be public (viewing a shared board preview) and others should require login (creating or deleting a card). How would you use [Authorize] and [AllowAnonymous] to enforce those rules?
Module 6: HTML, CSS & React
Web Foundations and Component-Driven UI
Start with HTML structure and CSS layout, then build interactive user interfaces with React: components, state, hooks, and data fetching.
Learning Objectives - Identify and use semantic HTML elements to structure a web page
- Apply CSS layout using the box model, Flexbox, and responsive media queries
- Create and render a basic React component
- Pass data between components using props
- Manage component-level state with the useState hook
- Fetch data from an API and display it using useEffect
- Explain the React component lifecycle at a practical level
What You'll Learn - HTML elements, semantic tags, forms, and attributes
- CSS selectors, the box model, and Flexbox layout
- Responsive design with media queries and CSS variables
- What React is and why components are its central concept
- Creating functional components and returning JSX
- Props: passing data from parent to child
- State with useState: reading and updating values
- Side effects and data fetching with useEffect
- Conditional rendering and list rendering with map
HTML Structure and Semantics
Building the skeleton of a web page
HTML is the language that defines the structure and meaning of content on a web page. Every visible element: a heading, a paragraph, a button, an image: is represented by an
HTML element, written as a tag like
<h1> or
<p>. Elements can be nested inside one another to create a hierarchy, and each element can carry
attributes that provide extra information:
class and
id identify elements for styling or scripting,
href sets a link destination,
src points to an image file, and
type,
name, and
placeholder configure form inputs. Understanding nesting and attributes is the foundation for reading and writing any HTML, whether you are building a static page or a React component.
Semantic tags and why they matter
Early HTML used generic
<div> tags for everything, giving browsers and screen readers no information about what a section of the page actually was.
Semantic HTML solves this with purpose-built tags:
<header> wraps the top banner,
<nav> holds navigation links,
<main> marks the primary content,
<section> groups related content,
<article> holds self-contained pieces like a blog post, and
<footer> closes the page. These tags carry meaning that screen readers use to help visually impaired users navigate, and that search engines use to understand page structure: making semantic HTML both an accessibility and an
SEO practice. You will also encounter the distinction between
block elements (like
<div>,
<p>,
<h1>) that take up the full available width, and
inline elements (like
<span>,
<a>,
<strong>) that flow within a line of text.
Forms and user input
Forms are how web pages collect information from users, and in the Chello app you will build forms for creating and editing cards and lists. A
<form> element wraps a set of
input elements:
type="text" for plain text,
type="email" and
type="password" for specialised input with built-in validation,
<select> for dropdowns, and
<textarea> for multi-line text. A
<button type="submit"> triggers form submission. The
name attribute on each input identifies the field, and
placeholder provides hint text. In React you will control these inputs with state rather than letting the browser handle them natively, but understanding the underlying HTML elements makes the React patterns much easier to follow.
Key Insight: Semantic HTML tags like <header>, <main>, and <article> tell browsers, screen readers, and search engines what your content actually means: not just what it looks like.
Real-World Example: <header><nav><a href="/">Home</a></nav></header><main><section><h1>My Board</h1><article class="card" id="card-1"><h2>Task</h2></article></section><form><input type="text" name="title" placeholder="Card title" /><button type="submit">Add Card</button></form></main><footer><p>Chello App</p></footer>
Q: Which HTML element is most appropriate for wrapping the main navigation links of a web page?
The <nav> element is the semantic HTML5 tag specifically designed for navigation links. Screen readers announce it as a navigation landmark, helping users jump directly to navigation without reading through other content first.
A board contains lists, and each list contains cards. How would you use semantic HTML elements to represent that hierarchy? Is there a case where a plain div would actually be the right choice?
CSS Fundamentals
Selectors and the box model
CSS tells the browser how HTML elements should look. You connect styles to elements using
selectors: an
element selector like
p targets every paragraph, a
class selector like
.card targets any element with that class, and an
id selector like
#submit-btn targets a single unique element.
Descendant selectors like
.card h2 target elements only when they appear inside a specific parent. Every element on the page is a rectangular box governed by the
box model:
content is the actual text or image,
padding is transparent space inside the border,
border is the visible edge, and
margin is transparent space outside the border that separates the element from its neighbours. Misunderstanding the box model: particularly forgetting that padding and border add to an element's total size by default: is the source of many layout headaches for beginners.
Flexbox and responsive design
Flexbox is the modern way to lay out groups of elements in a row or column. Setting
display: flex on a container makes its children flexible items.
flex-direction controls whether they flow in a
row or
column,
justify-content distributes space along the main axis (try
space-between to push items to opposite ends),
align-items positions items on the cross axis (use
center to vertically centre them), and
gap adds consistent spacing between items without needing margins.
Responsive design ensures layouts adapt to different screen sizes.
Media queries like
@media (min-width: 768px) apply styles only when the screen is wide enough, letting you stack columns on mobile and display them side by side on desktop.
CSS variables and CSS Modules
CSS custom properties, commonly called CSS variables, let you define a value once and reuse it everywhere. You declare them on the
:root selector: for example
--color-primary: #1565c0: and then reference them anywhere with
var(--color-primary). This makes global changes (like updating your brand colour) a one-line edit rather than a site-wide find-and-replace. In React projects you will typically use
CSS Modules, where each component has its own
.module.css file and class names are automatically scoped to that component, preventing styles from accidentally affecting other parts of the application.
Key Insight: Flexbox handles the vast majority of real-world layout challenges with just five properties: display, flex-direction, justify-content, align-items, and gap.
Real-World Example: :root { --color-primary: #1565c0; --spacing-md: 16px; } .board { display: flex; flex-direction: row; gap: var(--spacing-md); } @media (max-width: 767px) { .board { flex-direction: column; } }
Q: In the CSS box model, which property creates space between an element's border and the elements around it?
Margin is the space outside an element's border, pushing neighbouring elements away. Padding is the space inside the border, between the border and the content. Confusing the two is one of the most common beginner CSS mistakes.
The Chello app has lists arranged in a horizontal row, and each list contains vertically stacked cards. How would you use Flexbox to achieve that two-axis layout? What properties would you put on the board container, and what on each list?
What Is React and Why Components?
The component model
React is a JavaScript library created by Facebook (now Meta) for building user interfaces. Its central idea is simple: your UI is made of
components - self-contained pieces that each handle their own appearance and behaviour. A page is built by assembling these components together, much like assembling physical blocks. A navigation bar is a component. A product card is a component. The add-to-cart button inside the card can be its own component.
This modular approach solves a problem that plagued traditional web development: as pages grew more complex, the HTML, CSS, and JavaScript became increasingly tangled together. With React, each component owns its own code and state. You can change a product card component without touching the checkout component. You can reuse the same card component to display dozens of different products from a single definition.
The virtual DOM
React keeps a lightweight representation of the page in memory called the
virtual DOM. When data changes, React updates its virtual DOM first, then compares it to the previous version to find the minimum set of actual DOM changes needed. This diffing process means React only updates the parts of the page that actually changed, keeping the UI fast and smooth even on complex pages with frequent updates.
JSX: HTML inside JavaScript
React components return
JSX - a syntax that looks like HTML embedded inside JavaScript. JSX is compiled into JavaScript function calls before the browser sees it. You can embed JavaScript expressions directly inside JSX using curly braces: wrapping a variable name in curly braces inserts its current value into the output. This mixing of structure and logic in one place feels unusual at first but makes components compact and self-contained.
React Component Tree - assembling reusable components into a complete UI
Watch video: What Is React and Why Components?
Key Insight: React builds UIs from reusable components. Each component owns its appearance and behaviour. Assembling them creates complete pages.
Real-World Example: A Twitter/X feed uses components: a Tweet component renders one tweet; a Feed component renders a list of Tweet components; a Profile component holds user info. Each can change independently.
Q: What problem does React's component model solve in web development?
React components break the UI into self-contained, reusable pieces. Each component manages its own code and state. Changes to one component do not risk breaking others, which makes complex UIs manageable.
Think about a web page or app you use often. Can you identify three or four distinct components on the page? What data and behaviour does each one own?
Props: Passing Data to Components
What props are
Props (short for properties) are the mechanism React uses to pass data from a parent component to a child component. They are how you make components reusable: instead of hardcoding a product name inside a ProductCard component, you pass the name as a prop from the parent. The same ProductCard component can then display any product, depending on what props it receives.
How props work in practice
In JSX, you pass props the same way you write HTML attributes:
<ProductCard name="Notebook" price={12.99} />. Numbers and JavaScript expressions go inside curly braces; plain strings can use quotes directly. Inside the ProductCard component function, you receive props as a parameter object and access values like
props.name. A very common modern pattern is to use destructuring in the parameter list, extracting the props directly into named local variables. This makes the component body cleaner and more readable.
Props flow one way
A critical rule in React is that
props flow one way: from parent to child only. A child component cannot directly modify the props it receives - they are read-only from the child's perspective. This one-way data flow makes the application easier to reason about: if a value changes, you know the change came from above in the component tree, not from a sibling or child acting independently.
When a child needs to communicate back to its parent - for example when a button is clicked - it calls a
callback function passed down as a prop. The parent provides the function; the child calls it at the right moment. This architecture keeps data ownership clear: the component that owns a piece of data is responsible for updating it, and other components receive it as props and treat it as read-only.
Key Insight: Props flow one way: parent to child only. Children read props but cannot modify them. This one-way data flow makes application state easier to reason about.
Real-World Example: <ProductCard name="Notebook" price={12.99} inStock={true} /> passes three props. Inside ProductCard, { name, price, inStock } destructures them into local variables ready to use in JSX.
Q: In React, which direction do props flow?
Props flow one way in React: from parent component to child component. Children cannot modify props directly. To communicate back to a parent, a child calls a callback function that the parent passed down as a prop.
Can you think of a UI element that would need to receive different data each time it renders, like a product card or a user profile tile? What props would it need?
State with useState
What state is
State is data that a component owns and can change over time. When state changes, React automatically re-renders the component to reflect the new data in the UI. This is the core of React's reactivity: you update data, React updates the screen. You do not manually manipulate DOM elements - you just keep the data accurate and let React handle the rendering.
The useState hook
In functional components, state is managed with the
useState hook. You call it with an initial value and it returns a pair: the current state value and a function to update it. By convention, destructuring names them together:
const [count, setCount] = useState(0); creates a count variable starting at 0 and a setCount function to update it. Calling
setCount(count + 1) updates the value and triggers a re-render. You must always use the setter function - never modify the state variable directly, as direct mutation does not trigger re-renders and leaves the UI out of sync.
State is local and can be lifted
Each component instance has its own independent state. If you render three ProductCard components, each has its own isExpanded state. Toggling one does not affect the others. When multiple components need to share the same state, the solution is to move that state up to their nearest common parent - a pattern called
lifting state up. The parent then passes the shared state down as props.
State should contain the minimum amount of data needed to represent the current UI. Derived values - totals, filtered lists, formatted strings - should be computed from state during each render rather than stored as additional state variables. Keeping state minimal reduces the risk of different state values becoming inconsistent with each other, which is one of the most common sources of confusing bugs in React applications.
Watch video: State with useState
Key Insight: Never modify React state directly. Always use the setter function. Direct mutation does not trigger re-renders and leaves the UI out of sync with the data.
Real-World Example: const [isOpen, setIsOpen] = useState(false); <button onClick={() => setIsOpen(!isOpen)}>Toggle</button> - clicking the button flips the boolean and React re-renders the component automatically.
Q: What happens when you call a state setter function (like setCount) in React?
Calling a state setter updates the state value and schedules a re-render of the component. React then re-runs the component function with the new state value, producing updated JSX that gets applied to the DOM.
Think about interactive elements you have used - tabs, accordions, shopping carts, form fields. What state would each component need to track? Would that state be local or shared?
Loading Data with useEffect
What a side effect is
React components are designed to be
pure: given the same props and state, they always render the same output. But most real applications also need to do things outside pure rendering - fetching data from an API, setting a timer, updating the page title, or subscribing to a data stream. These are called
side effects because they reach outside the component's own rendering logic. React's
useEffect hook is the correct place to put them.
Using useEffect for data fetching
The basic pattern: declare a state variable to hold the loaded data, then use useEffect to fetch it and store the result in state. The second argument to useEffect is the
dependency array. It controls when the effect re-runs. An empty array means "run once when the component first mounts." Listing variables in the array means "re-run whenever any of these change." Omitting the array entirely means "re-run after every render" - which causes an infinite loop and is almost never what you want.
Handling loading and error states
A complete data-fetching implementation uses three state variables: the data itself, a loading boolean, and an error value. Set loading to true before the fetch starts and false after it completes, and capture any error if the fetch fails. Your component then renders different UI based on these values: a loading spinner while waiting, an error message if something went wrong, and the actual data once everything is ready.
This three-state approach covers the full lifecycle of an asynchronous operation. Many beginners skip the loading and error states, producing components that show nothing while data loads and crash silently when the API fails. Handling all three states adds only a few lines of code but significantly improves the robustness and user experience of every data-loading component you write.
Watch video: Loading Data with useEffect
Key Insight: The dependency array in useEffect controls when the effect re-runs. An empty [] means run once on mount. Omitting it means run after every render - usually a bug.
Real-World Example: useEffect(() => { fetch("/api/products").then(r => r.json()).then(data => setProducts(data)); }, []); - fetches products once when the component mounts and stores them in state for rendering.
Q: What does an empty dependency array [] mean in a useEffect call?
An empty dependency array [] tells React to run the effect only once - after the first render, when the component mounts. This is the standard pattern for fetching data that should load once when a component first appears.
Action step: Think about the last web page you built or used that displays data from a server. At what point in the page lifecycle should that data be loaded? What should the user see while waiting?
Rendering Lists and Conditional UI
List rendering with map
Most React components need to display collections of data - a list of products, a set of comments, a table of rows. The standard approach is to use JavaScript's
map method to transform an array of data objects into an array of JSX elements. Each JSX element in the list must have a unique
key prop set to a stable identifier, typically the item's database ID. React uses these keys to identify which items changed, were added, or were removed when re-rendering - without keys, React falls back to position-based comparison, which is slower and can produce display bugs when items are reordered.
Conditional rendering
Components regularly need to show different UI based on state or props. The two most common patterns are the
ternary operator and the
&& short-circuit. The ternary renders one of two choices based on a condition. The && operator renders the right side only if the left side is truthy - perfect for optional UI elements like error banners, loading spinners, or premium badges that appear only under certain conditions.
Forms and controlled inputs
HTML form inputs in React are typically
controlled components: their value is bound to a state variable and every keystroke updates the state via an onChange handler. This makes the current form data always available in state without querying the DOM. An input for a search term binds its value to a searchText state variable and updates it on every keystroke. When the form is submitted, searchText already holds the current value. The controlled input pattern keeps the UI and the data perfectly in sync at all times, which makes building features like live search filtering and form validation straightforward.
Key Insight: Always give list items a unique key prop when rendering with map. React uses keys to efficiently update only the items that changed.
Real-World Example: products.map(p => <ProductCard key={p.id} name={p.name} price={p.price} />) - renders a ProductCard for each product. The key={p.id} tells React which card corresponds to which product.
Q: Why must each JSX element in a list rendered with map have a unique key prop?
React uses the key prop to track list items across re-renders. With unique keys, React can efficiently update only the items that changed. Without keys, React uses position-based comparison which is slower and can cause subtle display bugs.
How do you currently display lists of items in web pages you build or maintain? Do you use templates, server-rendered HTML, or manual DOM manipulation? How would React's approach differ?
Module 7: Full-Stack Integration
Connecting React, C# API, and SQL Server
Wire together every layer of the stack: build a C# Web API, expose endpoints that the React front-end calls, and read from SQL Server to complete the full data flow.
Learning Objectives - Explain the three-tier architecture connecting React, C# API, and SQL Server
- Create a C# Web API project with controller endpoints
- Call an API from React using fetch and display the response
- Handle CORS to allow cross-origin requests from React to the API
- Trace a user action end-to-end from UI click to database record
What You'll Learn - What a REST API is and why it separates concerns
- Creating an ASP.NET Core Web API project
- Controllers, routes, and HTTP verbs (GET, POST, PUT, DELETE)
- Returning JSON from C# action methods
- Calling an API from React with fetch and useEffect
- Handling CORS in ASP.NET Core
- Connecting C# to SQL Server with ADO.NET or Entity Framework
- Tracing data from React button click to database and back
The Three-Tier Architecture
A full-stack web application separates work across three layers that each have a distinct responsibility. Understanding this separation is the mental model that makes the rest of the stack click into place.
The three layers
The presentation layer is the React front-end: HTML, CSS, and JavaScript running in the user's browser. It handles what the user sees and interacts with. It does not touch the database directly. The business logic layer is the C# Web API: a server-side application that receives HTTP requests, applies rules and transformations, and returns data. It is the gatekeeper between the browser and the data. The data layer is SQL Server: a relational database that persists information and responds to queries.
Why separate them?
Separation has two practical benefits. Security: the browser never connects directly to the database. The API controls what data is exposed and enforces authentication. If you build a mobile app later, it uses the same API. Flexibility: you can replace the front-end framework (swap React for Vue) or the database (swap SQL Server for PostgreSQL) without rebuilding everything else.
Three-Tier Architecture - React, C# API, and SQL Server
How requests flow
The user clicks a button in React. React uses fetch() to send an HTTP request to the API - for example, GET /api/products. The C# controller receives the request, queries SQL Server, and serialises the results as JSON. The JSON arrives back in React, which updates state and re-renders the component. The whole round-trip typically takes under 100ms on a local machine.
Watch video: The Three-Tier Architecture
Key Insight: The API layer is the security boundary between your browser and your database. The browser never connects to SQL Server directly.
Q: In a three-tier web application, what is the role of the C# Web API?
The C# Web API is the middle tier. It receives HTTP requests from React, applies business logic, queries SQL Server, and returns data as JSON. This separation keeps the database secure and allows multiple front-end clients to use the same API.
In your own words, explain why you would NOT connect React directly to SQL Server. What problems does the API layer prevent?
Creating a C# Web API
ASP.NET Core Web API is the .NET framework for building HTTP endpoints that return JSON. Creating a project takes one command, and the default template already includes a working example endpoint.
Scaffolding the project
In the terminal, run dotnet new webapi -n MyApi. This creates a project with a WeatherForecast example controller, Program.cs for startup configuration, and a .http file for quick testing. Open the folder in VS Code. Run dotnet run and visit the Swagger UI at http://localhost:{port}/swagger - it lists all endpoints and lets you call them interactively.
Controllers and routes
A controller is a C# class that groups related endpoints. Decorate the class with [ApiController] and [Route("api/[controller]")] - the route uses the controller name automatically (ProductsController becomes /api/products). Each public method decorated with [HttpGet], [HttpPost], [HttpPut], or [HttpDelete] becomes an endpoint. The HTTP verb determines what kind of operation the endpoint performs.
Action methods and return types
Return IActionResult to give full control over the HTTP response. Return Ok(data) for 200 with a body, NotFound() for 404, BadRequest() for 400, and Created() for 201 after an insert. Returning data directly (e.g. List
) also works and automatically serialises to JSON. For async database calls, use async Task and await the database operation.
Dependency injection
ASP.NET Core uses built-in dependency injection. Register your services in Program.cs: builder.Services.AddScoped(). Controllers declare their dependencies in the constructor: public ProductsController(IProductService service) { _service = service; }. The framework provides the service automatically when the controller is instantiated. This pattern makes code testable and decoupled - you can swap in a mock service for unit tests. Watch video: Creating a C# Web API
Real-World Example: [HttpGet] public async Task<IActionResult> GetAll() { var products = await _service.GetAllAsync(); return Ok(products); }: a minimal GET endpoint that returns all products as JSON.
Q: What does the [HttpGet] attribute on a controller method do?
[HttpGet] tells ASP.NET Core to invoke this method when a GET request arrives at the controller's route. Similarly, [HttpPost] handles POST, [HttpPut] handles PUT, and [HttpDelete] handles DELETE. The HTTP verb determines which action runs.
What endpoints would a basic e-commerce API need? List them as HTTP verbs and paths, for example: GET /api/products, POST /api/orders.
Calling the API from React
Once the C# API is running, React needs to call it. The browser's built-in fetch() function makes HTTP requests and returns a promise - perfect for use inside useEffect to load data when a component mounts.
A basic fetch pattern
Inside useEffect: fetch('http://localhost:5000/api/products').then(res => res.json()).then(data => setProducts(data)). The fetch call returns a promise that resolves to a Response. Calling .json() on the Response returns another promise with the parsed data. setProducts updates state and triggers a re-render. The empty dependency array [] means this runs once when the component mounts, not on every render.
Using async/await for readability
Async/await is cleaner: const fetchProducts = async () => { const res = await fetch('...'); const data = await res.json(); setProducts(data); }. Call fetchProducts() inside useEffect. Wrap in try/catch to handle network failures gracefully. Update an error state variable so the component can display a meaningful message if the request fails.
Sending data with POST
For creating records, send a POST with a JSON body: fetch('/api/products', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newProduct) }). The Content-Type header tells the API to expect JSON. The body is the serialised product object. The API receives this as a C# model object thanks to ASP.NET Core's automatic deserialization.
CORS: Allowing cross-origin requests
By default, browsers block fetch requests from one origin (http://localhost:3000) to a different origin (http://localhost:5000). This is Cross-Origin Resource Sharing (CORS). To allow it, add CORS configuration in the API's Program.cs: builder.Services.AddCors(options => options.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); then app.UseCors() before app.UseRouting(). In production, restrict AllowAnyOrigin to your specific front-end domain.
Watch video: Calling the API from React
Key Insight: CORS must be configured on the API server - the browser enforces it, not React. Without CORS headers in the API response, the browser silently blocks the data.
Q: Where does CORS configuration need to be set to allow React to call the C# API?
CORS is a browser security policy. The browser checks whether the API server's response includes the correct CORS headers (Access-Control-Allow-Origin). These headers must be added in the C# API configuration - the browser enforces the rule, but the server provides the permission.
After getting data from the API into React state, what would you need to do to display it as a formatted table or card list in the UI?
Connecting C# to SQL Server
The API connects to SQL Server to persist and retrieve data. Two popular approaches exist: ADO.NET for raw SQL control, and Entity Framework Core for an object-oriented abstraction. For beginners learning the full stack, starting with Dapper (a lightweight micro-ORM) or plain ADO.NET makes the SQL visible and concrete.
Connection strings
The connection string tells C# how to reach the database: "Server=localhost;Database=MyDb;Trusted_Connection=True;TrustServerCertificate=True". Store this in appsettings.json under ConnectionStrings, not hard-coded. Read it in Program.cs: builder.Configuration.GetConnectionString("DefaultConnection"). Never commit connection strings with real credentials to source control.
ADO.NET basics
ADO.NET is the low-level .NET database API. Open a SqlConnection, create a SqlCommand with your SQL, call ExecuteReaderAsync() to read rows, or ExecuteNonQueryAsync() for INSERT/UPDATE/DELETE. The result is a SqlDataReader you iterate with while (reader.ReadAsync()). It gives full control over exactly what SQL runs and is efficient for simple queries.
Entity Framework Core
Entity Framework Core (EF Core) maps C# classes to database tables. Define a DbContext with DbSet properties for each table. Use LINQ to query: await _context.Products.Where(p => p.Price > 50).ToListAsync(). EF translates this to SQL automatically. For inserts: _context.Products.Add(product); await _context.SaveChangesAsync(). EF Core can also generate migrations - SQL scripts that create or modify tables - from your C# model changes.
The repository pattern
A common practice is wrapping database access in a repository class with an interface. IProductRepository defines methods like GetAllAsync(), GetByIdAsync(), AddAsync(). The controller depends on the interface, not the concrete class. This makes testing straightforward: inject a mock repository in unit tests instead of a real database. This pattern also centralises all SQL or EF queries in one place.
Real-World Example: var products = await _context.Products.Where(p => p.IsActive).OrderBy(p => p.Name).Take(20).ToListAsync();: EF Core query for the first 20 active products alphabetically.
Q: Where should database connection strings be stored in an ASP.NET Core project?
Connection strings belong in appsettings.json (or environment variables for production). Hard-coding credentials in source files is a security risk - anyone with repo access sees the password. Use appsettings.Development.json for local development and environment variables in production.
What is one advantage of using Entity Framework Core over writing raw SQL strings in your C# code? What is one advantage of writing raw SQL instead?
End-to-End: Tracing a Request
The best way to cement your understanding of the full stack is to trace a single user action all the way from a React button click to a SQL query and back. This end-to-end view shows exactly where each layer's responsibility begins and ends.
Scenario: Adding a product
The user fills in a form in React (product name and price) and clicks "Add Product". The React onSubmit handler reads the state variables and calls fetch with a POST request, serialising the product object as JSON in the request body. The request travels from the browser to the C# API server.
API receives and processes
The ASP.NET Core routing middleware matches the POST /api/products request to the Create method on ProductsController. The [HttpPost] attribute and route confirm the match. ASP.NET Core automatically deserialises the JSON body into a Product C# object. The controller validates the object (ModelState.IsValid), then calls the repository or EF context to save it. The database assigns an ID, and the controller returns Created(201) with the new product including its ID.
Response flows back to React
The API response arrives in React's fetch promise. The component parses the JSON response, reads the new product (with its server-assigned ID), and adds it to the products array in state with setProducts([...products, newProduct]). React re-renders the list and the new item appears. The form is cleared. The whole flow from click to database to updated UI completes in a fraction of a second.
What to trace when debugging
When something goes wrong, trace it layer by layer. Browser DevTools Network tab shows the HTTP request and response - check status code, headers, and body. If the request never leaves, the issue is in React. If the API returns an error, use VS Code debugger or logging in C#. If the API works but data is wrong, query the database directly in SSMS. Each layer has tools to inspect it independently. This systematic debugging approach saves hours of guesswork.
Key Insight: Always debug full-stack issues layer by layer: browser DevTools for the React/network layer, VS Code debugger for the API, and SSMS for the database.
Q: After a React component receives a successful API response with a new record, what should it do to display the new item?
React re-renders components when state changes. After receiving the new item from the API, update state - for example setProducts([...products, newItem]) - to include it. React detects the state change, re-runs the component, and the new item appears in the UI automatically.
Walk through a different scenario end-to-end: a user clicking "Delete" on an item. What happens in React, the API, and the database?