Building a Modern Full-Stack Application: Architecture First
> **Source:** [Dev.to – Building a Modern Full‑Stack Application Architecture – First](https://dev.to/purav_patel_13/building-a-modern-full-stack-application-architecture-first-4b6p)
# The Journey Begins
Ever started a project that seemed simple at first, only to watch it spiral into a tangled mess of spaghetti code? I've been there. But what if I told you that spending time **upfront** on architecture could save you hundreds of hours of refactoring later?
In this series, I'm going to walk you through building a **production‑ready full‑stack application** with clean‑architecture principles. This isn’t theoretical fluff—this is real code, real patterns, and real lessons learned from building an **enterprise‑level tutoring management system**.What We’ll Build
- Backend – .NET 9 Web API
- Frontend – React 19 + TypeScript
- Database – PostgreSQL with Entity Framework Core
- Auth – Azure AD authentication
- Extras – Real‑time features & background processing
But more importantly, we’ll build it the right way—with clean architecture, proper layering, and maintainable patterns that scale.
Why Architecture Matters
In this first post we’ll understand why architecture matters by looking at what happens when you skip it. We’ll see the real problems that emerge and why “we’ll fix it later” never works.
Typical Timeline When You Skip Architecture
| Time | What Happens | Consequences |
|---|---|---|
| Week 1 | “Let’s just get something working” | • Direct DB calls in controllers • No thought to testing • “We’ll clean it up later” |
| Month 3 | “We need to add features fast” | • Copy‑paste existing code • Each developer does things differently • Technical debt accumulating |
| Month 6 | “Why is everything breaking?” | • Changes in one place break unrelated features • Can’t add tests (too tightly coupled) • Fear of touching existing code |
| Month 12 | “We need to rewrite this” | • Too expensive to fix • Business pressure to keep adding features • Team morale drops |
The truth: Later never comes. Technical debt compounds like credit‑card interest. What takes 1 hour to do right initially can take 10 hours to fix later—or 100 hours to rewrite.
Two Approaches
Code‑First (Bad)
Start coding → Hit problems → Try to refactor → Too late → Live with mess- ✅ Fast initial progress
- ❌ Slows down dramatically over time
- ❌ Hard to test
- ❌ Difficult to maintain
- ❌ Expensive to change
Architecture‑First (Good)
Plan architecture → Implement with patterns → Maintain structure → Scale easily- ⚠️ Slower initial start (1‑2 days planning)
- ✅ Consistent velocity over time
- ✅ Easy to test
- ✅ Simple to maintain
- ✅ Changes are isolated and safe
Real‑world analogy: Building a house. You can start nailing boards together and see progress immediately, but without a blueprint you’ll end up with crooked walls and no plumbing. Architects spend weeks on blueprints because it saves months during construction.
Example: Skipping Architecture
Below is real code that “works” but creates a host of problems.
// Controller.cs – What happens without architecture
public class StudentController : ControllerBase
{
[HttpGet]
public async Task<IResult> GetStudents()
{
// ❌ Direct database access in the controller
using var connection = new NpgsqlConnection("connection_string");
var students = await connection.QueryAsync<Student>(
"SELECT * FROM Students"); // ← raw SQL, selects everything
// ❌ Business logic in the controller
foreach (var student in students)
{
// Example of ad‑hoc logic that belongs in a service layer
if (student.Age < 18)
student.IsMinor = true;
}
return Results.Ok(students);
}
}What’s wrong?
| # | Problem | Why it matters |
|---|---|---|
| 1 | Raw SQL in the controller | • The controller now knows table/column names. • Any schema change forces a controller change. • SELECT * pulls unnecessary columns.• Adding parameters later opens the door to SQL‑injection. |
| 2 | Direct DB connection handling | • Connection‑lifetime logic is scattered throughout the UI layer. • No central place to manage retries, logging, or transaction scopes. |
| 3‑7 | Business logic & entity exposure | • Violates separation of concerns – the controller should only orchestrate, not process data. • Makes reuse difficult (other endpoints can’t share the same logic). • Exposes internal DB schema to API consumers, coupling the public contract to the persistence model. |
Analogy: It’s like a restaurant waiter walking into the kitchen, cooking the food themselves, and serving it. The waiter should only take orders and deliver food—not operate the stove.
A quick glimpse of a better structure
// Controllers/StudentController.cs
[ApiController]
[Route("api/[controller]")]
public class StudentController : ControllerBase
{
private readonly IStudentService _service;
public StudentController(IStudentService service) => _service = service;
[HttpGet]
public async Task<IResult> GetStudents()
=> Results.Ok(await _service.GetAllAsync());
}
// Services/IStudentService.cs
public interface IStudentService
{
Task<IEnumerable<StudentDto>> GetAllAsync();
}
// Services/StudentService.cs
public class StudentService : IStudentService
{
private readonly IStudentRepository _repo;
public StudentService(IStudentRepository repo) => _repo = repo;
public async Task<IEnumerable<StudentDto>> GetAllAsync()
{
var entities = await _repo.GetAllAsync();
return entities.Select(e => new StudentDto
{
Id = e.Id,
Name = e.Name,
Age = e.Age,
IsMinor = e.Age < 18
});
}
}
// Repositories/IStudentRepository.cs
public interface IStudentRepository
{
Task<IEnumerable<Student>> GetAllAsync();
}
// Repositories/StudentRepository.cs
public class StudentRepository : IStudentRepository
{
private readonly IDbConnection _db;
public StudentRepository(IDbConnection db) => _db = db;
public Task<IEnumerable<Student>> GetAllAsync()
=> _db.QueryAsync<Student>("SELECT Id, Name, Age FROM Students");
}The controller now only coordinates the request, the service contains business rules, and the repository handles data access. This separation eliminates the problems highlighted above.
Breaking Down the Raw SQL Query
var students = await connection.QueryAsync("SELECT * FROM Students");- Raw SQL query – Direct command written in Structured Query Language (SQL), the language databases understand. “Raw” means you write the exact SQL yourself instead of using a higher‑level abstraction like EF Core.
- Maps results to
Studentobjects – The database returns rows (think of a spreadsheet).QueryAsyncconverts each row into a C#Studentinstance.
Example result set
Mapped to C# objects
new Student { Id = 1, Name = "John Doe", Age = 20, Email = "j@s.com" };
new Student { Id = 2, Name = "Jane Doe", Age = 19, Email = "ja@s.com" };Why This Is Bad
- Tight coupling – Changing the table name or column list forces you to edit the controller.
- Performance –
SELECT *pulls every column even when you need only a few. - Security – Concatenating user‑supplied parameters can lead to SQL injection.
What Is SQL Injection?
SQL injection occurs when an attacker tricks your application into executing malicious SQL commands. It’s one of the most dangerous security vulnerabilities.
Vulnerable example (DO NOT USE)
// NEVER DO THIS! ☠️ Extremely dangerous!
public async Task GetStudentByName(string name)
{
// Building query by concatenating user input
var query = $"SELECT * FROM Students WHERE Name = '{name}'";
var students = await connection.QueryAsync(query);
return Ok(students);
}Next Steps
In the upcoming posts we’ll refactor the above into a clean‑architecture solution:
- Domain layer – Pure business rules (
Studententity, use‑cases). - Application layer – Interfaces & DTOs.
- Infrastructure layer – EF Core repository, PostgreSQL connection.
- Presentation layer – Controllers that orchestrate use‑cases only.
Stay tuned—next time we’ll start with domain modeling and show how a tiny amount of upfront planning saves countless hours later. 🚀
The Problem: String‑Concatenated SQL
var query = "SELECT * FROM Students WHERE Name = '" + name + "'";
var student = await connection.QueryAsync(query);
return Ok(student);What happens when a normal user searches for “John”?
Query generated
SELECT * FROM Students WHERE Name = 'John'✅ Works fine, returns John’s record.
What happens when an ATTACKER enters:
John'; DROP TABLE Students; --Query generated
SELECT * FROM Students WHERE Name = 'John'; DROP TABLE Students; --'☠️ DISASTER!
- Returns John’s record.
- Deletes the entire Students table.
--comments out the remainder of the statement.
Your entire Students table is gone – all student data deleted forever.
Safer Alternative: Parameterised Query
const string sql = "SELECT * FROM Students WHERE Name = @Name";
var parameters = new { Name = name };
var student = await connection.QueryAsync<Student>(sql, parameters);
return Ok(student);- Using parameters (
@Name) lets the database treat the input as data, not executable code, eliminating the SQL‑injection risk.
Next post: We’ll begin with domain modeling and demonstrate how clean architecture helps keep the codebase safe, testable, and maintainable.
Even Worse Attacks
| Attack input | Resulting query | Effect |
|---|---|---|
' OR '1'='1 | SELECT * FROM Students WHERE Name = '' OR '1'='1' | ☠️ Returns all students (data exposure) |
'; UPDATE Students SET GPA = 4.0 WHERE Name = 'Attacker'; -- | SELECT * FROM Students WHERE Name = ''; UPDATE Students SET GPA = 4.0 WHERE Name = 'Attacker'; -- | ☠️ Changes grades (data manipulation) |
'; SELECT password, email FROM Users; -- | (adds a second statement) | ☠️ Steals credentials |
Why This Happens
- User input is treated as code instead of data.
- The database cannot distinguish between the intended query and the attacker’s injected commands.
- It’s like giving someone a form to fill out and they write instructions in the blank spaces that you then follow blindly.
The Safe Way – Parameterized Queries
public async Task<IActionResult> GetStudentByName(string name)
{
var query = "SELECT * FROM Students WHERE Name = @Name";
var student = await connection.QueryAsync(query, new { Name = name });
return Ok(student);
}What changes?
@Nameis a parameter placeholder.- The value is passed separately:
new { Name = name }. - The database driver automatically escapes the input.
- User input is treated as data, never as code.
Example with the same malicious input
- Input:
John'; DROP TABLE Students; -- - Query stays:
SELECT * FROM Students WHERE Name = @Name - Parameter value:
@Name = "John'; DROP TABLE Students; --" - The database treats the entire string as a name to search for, finds nothing, and returns an empty result.
✅ Your table is safe!
Real‑World Impact
| Year | Incident | Impact | Note |
|---|---|---|---|
| 2008 | Heartland Payment Systems | 134 million credit cards stolen | SQL injection was the entry vector |
| 2012 | Yahoo | 450 000 passwords leaked | Vulnerability stemmed from unsanitized queries |
| 2017 | Equifax breach | 147 million people affected | Later analysis uncovered an SQL‑injection flaw |
SQL injection has been listed in the OWASP Top 10 security risks for more than a decade.
Key Takeaway
Never concatenate user input directly into SQL statements.
Use one (or more) of the following mitigation techniques:
- Parameterized (prepared) statements
- Stored procedures with proper parameter handling
- Object‑relational mappers (ORMs) such as Entity Framework, Hibernate, or Django ORM, which automatically parameterize queries
By adopting these practices you eliminate the primary cause of SQL injection attacks.
Other Bad Practices in the Sample Code
1️⃣ Business Logic in the Controller
foreach (var student in students)
{
if (student.Age < 18)
{
// Business logic here
}
}Architecture is not overhead. Architecture is debt prevention.
What’s Next?
In Part 2 we’ll explore the different architectural approaches available:
- No Architecture (Script Pattern) – When is it okay?
- Traditional N‑Tier – Better, but still has problems.
- Active Record Pattern – Simple but limiting.
- Repository Pattern – Getting closer.
- Domain‑Driven Design – For complex domains.
Microservices – The Scaling Solution
Clean Architecture – The Sweet Spot ⭐
For each pattern, I’ll show you:
- Real code examples with line‑by‑line explanations
- What problems it solves
- What problems it creates
- When to use it (and when NOT to)
- Why Clean Architecture wins for most production applications
Why Architecture Matters
- ✅ “We’ll fix it later” never happens – Technical debt compounds exponentially.
- ✅ Without architecture, every change becomes dangerous – Fear of touching code kills velocity.
- ✅ SQL injection is real and devastating – Billions of dollars lost due to this vulnerability.
- ✅ Architecture is debt prevention, not overhead – 10 hours invested saves hundreds later.
- ✅ Controllers doing everything is a time bomb – Database, business logic, and HTTP all mixed together.
- ✅ Testing becomes impossible without separation – You can’t test what you can’t isolate.
- ✅ Team scalability requires structure – New developers need clear boundaries.
Discussion Prompt
Have you experienced the pain of “we’ll fix it later”?
What was the tipping point that made you invest in architecture?
Share your stories in the comments below!
Next in the Series
Part 2: Comparing Architectural Approaches – Finding the Right Pattern
Tags
#dotnet #csharp #architecture #softwaredevelopment #webapi #programming #technicaldebt #coding
This series is based on real experiences building an enterprise tutoring‑management system. All code examples have been generalized for educational purposes.