How to Transition from Medior to Senior Engineer: Start Thinking About the Domain

How to Transition from Medior to Senior Engineer: Start Thinking About the Domain

There is a point in every engineer's career where learning another framework, database, or cloud service stops moving you forward as fast as before. You can become faster with React, understand PostgreSQL better, learn Kubernetes, memorize design patterns, and still remain at roughly the same level of influence inside the company.

The transition from medior to senior starts when you stop thinking primarily about technology and start thinking about the domain and the business rules behind the software.

This is the secret playbook.

Senior engineers are not valuable because they know more React APIs. They are valuable because they can look at a messy business problem, understand the important concepts and constraints, model them clearly, and turn that understanding into software that can evolve.

A very practical way to start making this transition is to explore Domain-Driven Design and learn to build software with clear boundaries between the domain layer, application layer, and infrastructure layer.

Stop Putting the Framework in the Middle

A lot of applications start from technology.

We need React. We need an API. We need PostgreSQL. We probably need Redis. Which ORM should we use? Should we use Prisma or Drizzle? REST or GraphQL?

These can all be important decisions, but none of them explain the software you are actually building.

Imagine that we are building an equipment ordering system. The important questions are not initially about React or PostgreSQL. They are questions such as:

  • Who is allowed to order equipment?
  • Is approval required above a certain amount?
  • Can somebody order equipment while they already have a pending request?
  • Which equipment requires additional approval?
  • What happens when the yearly budget is exceeded?
  • Can an approved order still be cancelled?

That is the domain.

When you start asking these questions, something interesting happens. You suddenly participate in completely different conversations. Instead of arguing with another engineer about database choice, you can sit with operations, finance, product, or management and talk about how the company actually works.

That is where seniority starts becoming visible.

Start With Layered Architecture

One of the simplest architectural changes you can make is separating your application into three major layers:

project structure
src/
domain/
equipment-order/
EquipmentOrder.ts
Equipment.ts
specifications/
application/
order-equipment/
OrderEquipment.ts
EquipmentOrderRepository.ts
infrastructure/
persistence/
PostgresEquipmentOrderRepository.ts
ui/
react/
EquipmentOrderForm.tsx

The domain layer contains your business model and business rules. The application layer contains the use cases your application exposes. The infrastructure layer contains technical details such as databases, APIs, queues, filesystem access, email providers, or external services.

React is simply another adapter on top of the application.

The direction of dependencies is what matters:

dependency direction
React
Application
Domain
Infrastructure
Application

The domain should know nothing about React, PostgreSQL, REST, or whatever technology you happen to be using today.

Your business existed before Prisma. Hopefully it will exist after Prisma too.

Hexagonal Architecture Changes How You Think

Hexagonal architecture takes this idea further. Instead of your application depending directly on infrastructure, the application defines the infrastructure capabilities it needs.

Imagine our equipment ordering use case needs to store an order.

The application layer defines the contract:

EquipmentOrderRepository.ts
export interface EquipmentOrderRepository {
save(order: EquipmentOrder): Promise<void>
findPendingByEmployee(employeeId: string): Promise<EquipmentOrder[]>
}

Now we can implement the real infrastructure adapter:

PostgresEquipmentOrderRepository.ts
export class PostgresEquipmentOrderRepository
implements EquipmentOrderRepository
{
async save(order: EquipmentOrder): Promise<void> {
// PostgreSQL implementation
}
async findPendingByEmployee(
employeeId: string
): Promise<EquipmentOrder[]> {
// PostgreSQL implementation
return []
}
}

But the use case doesn't know that PostgreSQL exists.

OrderEquipment.ts
export class OrderEquipment {
constructor(
private readonly orders: EquipmentOrderRepository
) {}
async execute(command: OrderEquipmentCommand) {
// application orchestration
}
}

Infrastructure is injected into the application.

composition root
const repository =
new PostgresEquipmentOrderRepository()
const orderEquipment =
new OrderEquipment(repository)

That sounds like a small architectural detail, but it completely changes how you build software.

You can replace PostgreSQL with an in-memory implementation:

InMemoryEquipmentOrderRepository.ts
export class InMemoryEquipmentOrderRepository
implements EquipmentOrderRepository
{
private readonly orders: EquipmentOrder[] = []
async save(order: EquipmentOrder) {
this.orders.push(order)
}
async findPendingByEmployee(employeeId: string) {
return this.orders.filter(
order =>
order.employeeId === employeeId &&
order.status === "pending"
)
}
}

Now your application can be tested without a database, Docker container, HTTP server, or complicated testing environment.

test setup
const repository =
new InMemoryEquipmentOrderRepository()
const useCase =
new OrderEquipment(repository)

This is not about mocking everything because somebody told you that unit tests are good. The deeper idea is that infrastructure should not control the shape of your application.

That is an important senior-engineering mental model.

The Hardest Part Is the Domain Layer

Layered architecture is relatively easy. Interfaces are easy. Dependency injection is easy.

The tricky part is the domain.

A bad domain layer looks like this:

EquipmentOrder.ts
class EquipmentOrder {
id: string
employeeId: string
amount: number
status: string
}

This is basically a database table written as a TypeScript class.

It tells us almost nothing about the business.

This is what people usually call an anemic domain model.

A good domain model should communicate a story. When somebody opens the code, they should begin understanding how equipment ordering works inside the company.

For example:

EquipmentOrder.ts
export class EquipmentOrder {
private status:
| "draft"
| "pending"
| "approved"
| "rejected" = "draft"
constructor(
public readonly employeeId: string,
public readonly equipment: Equipment,
public readonly amount: number
) {}
submit() {
if (this.status !== "draft") {
throw new Error(
"Only draft orders can be submitted"
)
}
this.status = "pending"
}
approve() {
if (this.status !== "pending") {
throw new Error(
"Only pending orders can be approved"
)
}
this.status = "approved"
}
reject() {
if (this.status !== "pending") {
throw new Error(
"Only pending orders can be rejected"
)
}
this.status = "rejected"
}
}

Now the model starts communicating something.

But as the business becomes more sophisticated, putting every rule inside one entity eventually becomes messy. This is where domain patterns become useful.

Specification Pattern Is a Great Place to Start

The Specification pattern is one of my favorite ways to express business rules explicitly.

Instead of hiding a rule inside an if somewhere in an application service, we make the rule a domain concept.

Start with a generic specification:

Specification.ts
export interface Specification<T> {
isSatisfiedBy(candidate: T): boolean
and(
other: Specification<T>
): Specification<T>
or(
other: Specification<T>
): Specification<T>
not(): Specification<T>
}

We can create a base implementation:

CompositeSpecification.ts
export abstract class CompositeSpecification<T>
implements Specification<T>
{
abstract isSatisfiedBy(candidate: T): boolean
and(other: Specification<T>) {
return new AndSpecification(this, other)
}
or(other: Specification<T>) {
return new OrSpecification(this, other)
}
not() {
return new NotSpecification(this)
}
}

Then implement AND:

AndSpecification.ts
class AndSpecification<T>
extends CompositeSpecification<T>
{
constructor(
private readonly left: Specification<T>,
private readonly right: Specification<T>
) {
super()
}
isSatisfiedBy(candidate: T) {
return (
this.left.isSatisfiedBy(candidate) &&
this.right.isSatisfiedBy(candidate)
)
}
}

OR:

OrSpecification.ts
class OrSpecification<T>
extends CompositeSpecification<T>
{
constructor(
private readonly left: Specification<T>,
private readonly right: Specification<T>
) {
super()
}
isSatisfiedBy(candidate: T) {
return (
this.left.isSatisfiedBy(candidate) ||
this.right.isSatisfiedBy(candidate)
)
}
}

And NOT:

NotSpecification.ts
class NotSpecification<T>
extends CompositeSpecification<T>
{
constructor(
private readonly specification:
Specification<T>
) {
super()
}
isSatisfiedBy(candidate: T) {
return !this.specification.isSatisfiedBy(
candidate
)
}
}

Now we can start expressing actual domain rules.

OrderRequest.ts
interface OrderRequest {
amount: number
equipmentType: string
employeeBudgetRemaining: number
}

A specification for staying inside the employee budget:

WithinBudget.ts
export class WithinBudget
extends CompositeSpecification<OrderRequest>
{
isSatisfiedBy(request: OrderRequest) {
return (
request.amount <=
request.employeeBudgetRemaining
)
}
}

Another specification:

RequiresSecurityApproval.ts
export class RequiresSecurityApproval
extends CompositeSpecification<OrderRequest>
{
isSatisfiedBy(request: OrderRequest) {
return [
"laptop",
"mobile-device"
].includes(request.equipmentType)
}
}

And maybe expensive equipment requires management approval:

ExpensiveOrder.ts
export class ExpensiveOrder
extends CompositeSpecification<OrderRequest>
{
isSatisfiedBy(request: OrderRequest) {
return request.amount > 2000
}
}

Now we can compose rules:

rules.ts
const canBeAutomaticallyApproved =
new WithinBudget().and(
new ExpensiveOrder().not()
)

Or:

rules.ts
const requiresAdditionalApproval =
new RequiresSecurityApproval().or(
new ExpensiveOrder()
)

Read that again:

rules.ts
withinBudget.and(expensiveOrder.not())

The code starts speaking the language of the business.

That is exactly what you want.

You are not writing:

the imperative version
if (
request.amount <=
request.employeeBudgetRemaining &&
request.amount <= 2000
) {
// ...
}

The second version works technically, but the first one preserves the meaning of the rule.

That difference becomes massive as the system grows.

Put Application Use Cases Over the Domain

The application layer should orchestrate the domain rather than containing the business logic itself.

OrderEquipment.ts
export class OrderEquipment {
constructor(
private readonly orders:
EquipmentOrderRepository
) {}
async execute(command: {
employeeId: string
equipment: Equipment
amount: number
employeeBudgetRemaining: number
}) {
const existingOrders =
await this.orders.findPendingByEmployee(
command.employeeId
)
if (existingOrders.length > 0) {
throw new Error(
"Employee already has a pending equipment order"
)
}
const request = {
amount: command.amount,
equipmentType: command.equipment.type,
employeeBudgetRemaining:
command.employeeBudgetRemaining
}
const withinBudget = new WithinBudget()
if (!withinBudget.isSatisfiedBy(request)) {
throw new Error(
"Equipment order exceeds employee budget"
)
}
const order = new EquipmentOrder(
command.employeeId,
command.equipment,
command.amount
)
order.submit()
await this.orders.save(order)
return order
}
}

There is still an important design question here. Should "Employee already has a pending equipment order" be a rule in the application layer, a domain service, or another specification?

That answer depends on the domain.

And that is exactly the point.

DDD forces you to ask these questions instead of blindly putting everything in a service.ts file.

React Should Become Boring

Once you have this architecture, React becomes intentionally boring.

EquipmentOrderForm.tsx
type Props = {
orderEquipment: OrderEquipment
}
export function EquipmentOrderForm({
orderEquipment
}: Props) {
const [equipment, setEquipment] =
useState("")
const [amount, setAmount] = useState(0)
async function submit() {
await orderEquipment.execute({
employeeId: "employee-123",
equipment: new Equipment(
equipment
),
amount,
employeeBudgetRemaining: 3000
})
}
return (
<form
onSubmit={async event => {
event.preventDefault()
await submit()
}}
>
<input
value={equipment}
onChange={event =>
setEquipment(event.target.value)
}
/>
<input
type="number"
value={amount}
onChange={event =>
setAmount(
Number(event.target.value)
)
}
/>
<button type="submit">
Order equipment
</button>
</form>
)
}

React doesn't decide whether the order is allowed.

React doesn't calculate approval rules.

React doesn't know whether equipment above €2,000 requires additional approval.

It collects input and calls a use case.

That separation gives you freedom. Tomorrow the same use case might be called from a REST API, CLI tool, mobile application, background job, or AI agent.

The domain doesn't care.

This Is Why DDD Pushes You Toward Seniority

Something changes when you spend enough time modeling domains.

You start noticing concepts.

You start asking better questions.

You start listening to terminology.

You begin noticing that when the business person says approval, request, allocation, equipment assignment, and budget, they are probably not random words. They might represent completely different concepts with different lifecycle and constraints.

You become closer to the business department because you can finally speak about the business instead of constantly translating everything into technology.

And the business starts valuing your perspective.

Suddenly you are not only the engineer somebody calls when PostgreSQL is slow.

You become the engineer somebody asks:

“How should equipment ordering actually work?”

That is a much more powerful position.

The Paradox of Becoming Senior

There is one difficult part.

To become better at DDD, you need to get rid of thinking about technology all the time.

But at the same time, you need to master technology.

You need enough knowledge of TypeScript, React, testing, dependency injection, databases, concurrency, clean code, refactoring, and architecture that these things stop consuming most of your mental capacity.

That is the paradox.

A junior engineer struggles with syntax.

A medior engineer thinks about technologies and architecture.

A strong senior engineer can use those things almost automatically, which leaves mental space to think about the domain.

That is why DDD has a learning curve. You are learning domain modeling while simultaneously becoming disciplined enough technically to create clean boundaries around that model.

Stop Fighting About Frameworks

If most of your engineering conversations are arguments about framework choice, database choice, naming conventions, folder structures, or which library is better, you are probably playing a very small game.

Those things matter.

But businesses rarely fail because somebody selected PostgreSQL instead of MySQL.

They fail because the software doesn't represent the reality of the business well enough. Important constraints are missing. Concepts are confused. Processes are modeled incorrectly. Every new feature requires touching twenty unrelated things because the original model was wrong.

Instead of arguing about technologies, go explore the domain.

If you are working on equipment ordering, understand equipment ordering better than anyone.

Ask somebody from operations to walk you through the whole process.

What happens before the request?

What happens after it?

Who approves it?

Why?

What are the exceptions?

What are the constraints?

Which rules are regulatory?

Which rules exist only because of current company policy?

What happens when something goes wrong?

Then model it.

Build your application use cases on top of that domain model and initially use in-memory infrastructure if necessary.

You might be surprised how much software you can design before making any important infrastructure decision.

Take Agency

There is one more difference between medior and senior engineers that architecture books rarely explain.

Stop complaining about everything.

Real architects bring value to the business.

It is easy to say the architecture is bad, somebody else's code is bad, management doesn't understand engineering, another team made the wrong decision, or the existing system should have been designed differently.

That doesn't change anything.

If you see an important domain concept missing from the codebase, introduce it.

If infrastructure is tightly coupled with application logic, create a boundary.

If a business rule is hidden across five controllers, extract the rule and give it a name.

If nobody understands a part of the domain, talk to the experts and model it.

Take agency.

Don't delegate the responsibility for improving the system to some imaginary future architect.

You have the power to change things.

That is probably the biggest transition of all.

The path from medior to senior is not about becoming the person who knows the most technologies in the room.

It is about becoming the person who understands why the software exists, how the domain works, which constraints matter, and how to express that knowledge in a coherent design.

Start there.

Miodrag Vilotijević

Miodrag Vilotijević

Co-founder @ JigJoy

Building the future of agentic systems

With tools and technology we already have, we can build much more valuable systems than most projects today. We can write software that is a pleasure to use and a pleasure to work on; software that doesn't box us in as it grows, but creates new opportunities and continues to add value for its owners.
Eric Evans

Newsletter

For developers who want to learn how to build self-organizing agents.

We're organizing a hackathon