My Code Review Checklist — After 13 Years of Delivery
May 10, 2026 · Sekhar Babu Pandi
After 13+ years building enterprise systems — US payroll, supply chain, healthcare, e-commerce — I've reviewed hundreds of PRs. This is the checklist I run through every single time. No fluff, no theory. Every item maps to a real production incident.
Code reviews are one of the highest-leverage activities on any engineering team. A 30-minute review that catches a missing WHERE clause on an UPDATE statement can save you hours of production firefighting. But most developers don't have a structured way to think about them — they eyeball the diff, check a few things, and approve.
The six areas I check
Every PR goes through these six lenses in order. Requirements first — there's no point checking code quality if the code doesn't solve the right problem.
01 · Requirement Coverage
Does the code solve what the Jira ticket asks — fully, not partially?
Are all acceptance criteria and edge cases covered?
For a bug fix — is the root cause fixed, not just the symptom patched?
Any side effects on related modules or tickets?
Example 1 — Wrong scope, clean code
Ticket: "Fix overtime calculation for contractors." The dev fixes the calculation — the math is correct — but only applies it to salaried employees. The EmployeeType filter was never touched. The PR passes lint, unit tests pass, reviewer approves it. Production now silently miscalculates contractor overtime. Always re-read acceptance criteria before opening the diff.
Example 2 — Root cause vs symptom
Bug: "Invoice total shows wrong amount when discount > 50%." The fix adds a Math.Abs() guard on the final display value. The symptom disappears. But the discount sign logic in PricingService.ApplyDiscount() is still inverted — it'll surface again in reports, exports, and any other consumer. The right fix is the calculation, not the guard.
// ? Symptom fix — masks the bug everywhere else it's consumedvar total = order.Subtotal + Math.Abs(discount); // hides negative discount// ? Root cause fix — correct the sign in PricingServicepublic decimal ApplyDiscount(decimal price, decimal discountPct)
=> price - (price * discountPct / 100); // always returns positive reduction
Example 3 — Side effects on adjacent tickets
A dev modifies GetActiveEmployees() to filter out terminated employees with a new status flag. The ticket is correct. But three other features — payroll summary, headcount reports, and the audit export — all call the same method. After the PR merges, those screens silently drop terminated-but-still-payable employees. Always grep for every caller of a shared method before approving a signature or query change.
02 · Approach & Design
Is the solution proportional — not over or under engineered?
Is logic in the right layer? (service, not controller or UI)
Is existing code/utilities reused — no reinventing the wheel?
Consistent with how the codebase handles similar problems?
Example 1 — Business logic in the wrong layer
// ? Tax calculation inside a controller action
[HttpGet("salary/{id}")]
public IActionResult GetSalary(int id) {
var emp = _db.Employees.Find(id);
var tax = emp.Salary * 0.30m; // tax logic here = untestable, unreusablevar net = emp.Salary - tax;
return Ok(new { net });
}
// ? Controller is thin — logic lives in PayrollService
[HttpGet("salary/{id}")]
public IActionResult GetSalary(int id) {
var result = _payrollService.GetNetSalary(id);
return Ok(result);
}
Example 2 — Reinventing a utility that already exists
// ? Dev writes a custom date formatter from scratchpublic string FormatDate(DateTime d)
=> $"{d.Day:D2}/{d.Month:D2}/{d.Year}";
// ? DateHelper.ToDisplayDate() already exists in /Common/DateHelper.cspublic string FormatDate(DateTime d)
=> DateHelper.ToDisplayDate(d); // consistent across the whole platform
Reinvention is not just wasted effort — it creates two separate behaviours for the same concept. When the display format changes, only one gets updated. Grep for existing helpers before writing new ones.
Example 3 — Over-engineering a simple requirement
Ticket: "Add an 'Export to CSV' button for the employee list." The dev introduces a generic IExportStrategy<T> interface, a factory, and three strategy classes for CSV, Excel, and PDF — none of which were asked for. The CSV works but now there are 4 new files, a DI registration, and a pattern nobody else on the team uses. The right call: a single ExportService.ToCSV(employees) method. Design for what's asked, not what might be asked.
03 · Security
No hardcoded secrets — keys, passwords, connection strings in code?
Secrets pulled from Azure Key Vault or environment config?
All new endpoints protected with auth + authorization?
User input sanitized — no SQL injection or XSS risk?
No sensitive data (PII, tokens) written to logs?
Snyk passes clean — no new vulnerabilities introduced?
Example 1 — Hardcoded secret + missing auth
// ? Connection string in source — now in git history forevervar client = new ServiceBusClient(
"Endpoint=sb://prod.servicebus.windows.net/;SharedAccessKey=xK3mP9...");
// ? Endpoint has no [Authorize] — anyone can call it
[HttpGet("employees/payroll-data")]
public IActionResult GetPayrollData() { ... }
// ? Secret from config, endpoint locked downvar client = new ServiceBusClient(_config["ServiceBus:ConnectionString"]);
[Authorize(Policy = "PayrollAdmin")]
[HttpGet("employees/payroll-data")]
public IActionResult GetPayrollData() { ... }
Example 2 — PII leaking into logs
// ? SSN written to application log — a compliance incident waiting to happen
_logger.LogInformation("Processing employee SSN: {ssn}", employee.SSN);
_logger.LogDebug("Token: {tok}", authToken);
// ? Log reference IDs only — never the sensitive values themselves
_logger.LogInformation("Processing payroll record: {id}", employee.Id);
_logger.LogDebug("Auth flow completed for user: {id}", userId);
Example 3 — SQL injection via dynamic query
// ? String concatenation — classic injection vectorvar sql = "SELECT * FROM Employees WHERE Department = '"
+ deptName + "'"; // deptName = "'; DROP TABLE Employees;--"// ? Always use parameterised queriesvar emps = _db.Employees
.Where(e => e.Department == deptName)
.ToList();
// Or with Dapper:var emps = conn.Query<Employee>(
"SELECT * FROM Employees WHERE Department = @dept",
new { dept = deptName });
04 · Performance
No N+1 query problems — data not fetched inside a loop?
Large data sets paginated — not thousands of records at once?
No blocking async calls — no .Result or .Wait() in .NET?
No unnecessary API calls on frontend — debounce used where needed?
Angular subscriptions unsubscribed in ngOnDestroy?
Example 1 — N+1 query (the most common perf bug)
// ? 1 query for departments, then 1 per department — 51 DB hits for 50 deptsvar departments = _db.Departments.ToList();
foreach (var dept in departments) {
dept.Employees = _db.Employees
.Where(e => e.DeptId == dept.Id)
.ToList();
}
// ? Single query with eager loadvar departments = _db.Departments
.Include(d => d.Employees)
.ToList();
Example 2 — Blocking async causes thread starvation
// ? .Result blocks the thread — under load this deadlocks ASP.NETvar report = _reportService.GenerateAsync(month).Result;
_emailService.SendAsync(report).Wait();
// ? Properly awaited — thread is freed while I/O runsvar report = await _reportService.GenerateAsync(month);
await _emailService.SendAsync(report);
Example 3 — Angular memory leak (missing unsubscribe)
// ? Subscription never cleaned up — leaks on every route navigationexport class EmployeeListComponent {
ngOnInit() {
this.employeeService.getAll().subscribe(data => this.employees = data);
}
}
// ? Use takeUntilDestroyed (Angular 16+) or manual unsubscribeexport class EmployeeListComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.employeeService.getAll()
.pipe(takeUntil(this.destroy$))
.subscribe(data => this.employees = data);
}
ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}
05 · Code Quality
Method and variable names are self-explanatory — no x, temp, data2?
No dead code — commented-out blocks, unused imports?
No magic numbers or strings — constants or enums used?
Error handling in place — exceptions not swallowed silently?
Null checks where objects can realistically be null?
No console.log or debug artifacts left in frontend code?
Example 1 — Swallowed exception + magic numbers
// ? Silent catch — error disappears, nobody knows why payroll broketry {
await ProcessPayrollAsync(employeeId);
} catch (Exception) { } // TODO: handle later (it never gets handled)// ? Magic number — what does 3 mean?if (employee.Status == 3) { ... }
// ? Log + rethrow so the error surfacestry {
await ProcessPayrollAsync(employeeId);
} catch (Exception ex) {
_logger.LogError(ex, "Payroll failed for employee {Id}", employeeId);
throw;
}
// ? Self-documenting enumif (employee.Status == EmployeeStatus.OnLeave) { ... }
Example 2 — Null reference waiting to happen
// ? manager can be null if employee has no manager assignedvar managerName = employee.Manager.FullName; // NullReferenceException in prod// ? Null-conditional + fallbackvar managerName = employee.Manager?.FullName ?? "Unassigned";
Example 3 — Dead code and debug artifacts
// ? Old commented block + console.log left in Angular component
// var oldCalc = base * 1.18;
// return oldCalc;
console.log('DEBUG employee data:', this.employee);
return base * this.taxRate;
// ? Clean — only living code, no noisereturn base * this.taxRate;
Commented-out code is a readability tax on every future reader. If it needs to come back, git history has it. Delete it.
06 · Database / Stored Procedures
SELECT * avoided — only required columns fetched?
WHERE clause on every UPDATE and DELETE statement?
TRY/CATCH with ROLLBACK in stored procedures?
No cursors — set-based operations used instead?
Dynamic SQL checked for injection risk?
Example 1 — UPDATE with no WHERE clause
-- ? This updates EVERY row in the Employees table
UPDATE Employees SET Salary = @NewSalary
-- ? Always scope writes to the intended rows
UPDATE Employees
SET Salary = @NewSalary
WHERE Id = @EmployeeId
This is the most catastrophic single-line mistake in SQL. I've seen it hit production twice in my career. The PR reviewer is the last safety net.
Example 2 — Stored procedure with no transaction safety
-- ? Two writes, no transaction — if the INSERT fails, UPDATE already committed
CREATE PROCEDURE UpdateEmployeeSalary
@EmployeeId INT, @NewSalary DECIMAL(18,2)
AS BEGIN
UPDATE Employees SET Salary = @NewSalary WHERE Id = @EmployeeId
INSERT INTO AuditLog (EmployeeId, Action) VALUES (@EmployeeId, 'SALARY_CHANGE')
END
-- ? Atomic: both succeed or both roll back
CREATE PROCEDURE UpdateEmployeeSalary
@EmployeeId INT, @NewSalary DECIMAL(18,2)
AS BEGIN
SET NOCOUNT ON;
BEGIN TRANSACTION;
BEGIN TRY
UPDATE Employees SET Salary = @NewSalary WHERE Id = @EmployeeId
INSERT INTO AuditLog (EmployeeId, Action, ChangedAt)
VALUES (@EmployeeId, 'SALARY_CHANGE', GETUTCDATE())
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
THROW;
END CATCH
END
Example 3 — Cursor vs set-based operation
-- ? Cursor: processes 50,000 rows one at a time — takes minutes
DECLARE @Id INT
DECLARE cur CURSOR FOR SELECT Id FROM Employees WHERE IsActive = 1
OPEN cur
FETCH NEXT FROM cur INTO @Id
WHILE @@FETCH_STATUS = 0 BEGIN
UPDATE Employees SET LastReviewed = GETUTCDATE() WHERE Id = @Id
FETCH NEXT FROM cur INTO @Id
END
CLOSE cur; DEALLOCATE cur;
-- ? Set-based: single statement, same result, milliseconds
UPDATE Employees
SET LastReviewed = GETUTCDATE()
WHERE IsActive = 1
Why requirements come first
The biggest mistake junior developers make in code reviews is jumping straight to code style — naming, formatting, structure. Those things matter, but they're secondary to the most important question: does this code actually solve the right problem?
I've seen beautifully structured, well-named, zero-lint-error PRs that completely missed the acceptance criteria of the ticket. Clean code solving the wrong problem is worse than messy code solving the right one — it ships to production and the bug report comes back two weeks later with more context to untangle.
Practical tip: Before reading a single line of code, open the Jira ticket in a separate tab. Read the acceptance criteria. Then read the PR description. Only then open the diff. This 2-minute context switch saves you from approving solutions to the wrong problem.
The security mindset — especially on payroll systems
When you're working on systems that touch real money — payroll, invoicing, tax calculations — security reviews aren't optional hygiene. A missing authorization check on a payroll endpoint isn't a "medium severity" finding. It's a potential compliance incident.
At Cast & Crew, we had Snyk, Checkmarx, Trace3 Pen Test, and CAST running against our codebase. I eliminated 70% of flagged vulnerabilities across the platform. The pattern I saw most often: hardcoded credentials, missing endpoint authorization, and secrets in appsettings committed to the repo.
My rule: Treat every new endpoint as public until proven otherwise. If I don't see an [Authorize] attribute or equivalent policy check, I flag it. No exceptions.
How I explain this in interviews
? When asked "how do you approach code reviews?"
"I start with requirements — does this actually solve the Jira ticket completely, including edge cases. Then I evaluate the approach — is the solution proportional, is logic in the right layer, is it consistent with our patterns. Then I go deep on the code — error handling, null safety, security, no hardcoded secrets, API design correctness. For SQL and stored procedures I specifically check for missing WHERE clauses on writes and proper TRY/CATCH with ROLLBACK. On frontend I check that business logic isn't leaking into components, subscriptions are cleaned up, and error/loading states are handled. And I always verify Snyk passes clean after the PR — we had real vulnerabilities caught this way on our payroll platform."
The meta-principle
A code review is not a style audit. It's the last engineering checkpoint before code goes to production and becomes someone else's problem at 2am.
Every item in the checklist above maps to a real class of production incident I've either seen happen or caught before it shipped. Requirements coverage ? wrong behavior. Missing WHERE clause ? data corruption. No auth check ? security incident. N+1 query ? timeouts under load. Swallowed exception ? silent data loss.
The faster your team internalizes these patterns, the fewer production incidents you have — and fewer incidents means more time building features, which is what everyone actually wants.