1-step vs 2-step API Design

TL;DR

Instead of combining existing APIs in the frontend to create students and guardians separately, we built a new 1-step API that represents a single business operation. The decision criteria wasn’t about “number of steps” but about business boundaries and transaction responsibility.


Background: We Had APIs, But There Were Problems

CheckUS already had these APIs:

  • Student creation API
  • Guardian account creation API
  • Student-guardian connection API

We could implement “student+guardian registration” by calling these APIs sequentially from the frontend:

const student = await createStudent(...)
for (const guardian of guardians) {
  const account = await registerGuardian(...)
  await connectGuardianToStudent(student.id, account.id)
}

Initially, it seemed reasonable. Each API had a clear role and was reusable.

But problems emerged in production.


Problem 1: Frontend Has No Transactions

Combining multiple APIs in the frontend looks like one operation, but it’s actually multiple independent network requests.

✅ Student creation successful
❌ Guardian 1 creation failed (network error)

In this case:

  • Student is created
  • Some guardians are missing
  • Connection state is incomplete

There’s no way to automatically rollback this state.

The result:

  • Orphaned data
  • Manual cleanup required
  • Complex branching logic in frontend

Problem 2: Failure Responsibility Moves to Frontend

When combining APIs in the frontend, the frontend needs to know:

  • How far it succeeded
  • Which step failed
  • What to retry first
try {
  const student = await createStudent(data);
  const results = { success: [], failed: [] };

  for (const guardian of guardians) {
    try {
      const account = await registerGuardian(guardian);
      await connectGuardianToStudent(student.id, account.id);
      results.success.push(guardian);
    } catch (error) {
      results.failed.push({ guardian, error });
      // Partial failure handling logic goes into frontend
    }
  }
  // Complex state management...
} catch (error) {
  // Total failure handling...
}

Business state management responsibility leaks to the frontend. This isn’t a UI logic problem—it’s domain logic placed in the wrong location.


Problem 3: “Partial Success” Was Meaningless

This was the key point.

In CheckUS, “new student registration”:

  • Is meaningless with just a student created
  • Requires guardian accounts to start operations

This operation should have been All or Nothing from the beginning. The design allowing partial success didn’t match domain requirements.


Our Solution: Build a New 1-step API

We kept existing APIs and added one more API that represents the business unit:

@Transactional
public StudentWithGuardiansResponse createStudentWithGuardians(Request request) {
    User student = createStudentUser(request.getStudent());

    List<User> guardians = new ArrayList<>();
    for (GuardianInfo info : request.getGuardians()) {
        User guardian = createGuardianUser(info);
        connectGuardian(student, guardian);
        guardians.add(guardian);
    }

    return new Response(student, guardians);
}

The key points:

  • Multiple operations internally
  • Appears as one atomic operation externally
  • Everything rolls back on failure

“Were the Existing APIs Wrong?”

No.

Existing APIs:

  • Are still valid as independent operations
  • Can be used in other screens or flows

The problem was combining existing APIs in the frontend to use them like a single business operation.


2-step vs “Completely Separate Flows” Are Different

The 2-step we’re discussing doesn’t mean business-separated workflows.

For example:

Student registration
    ↓ (days later)
Guardian invitation

This is:

  • Different timing
  • Different user actions
  • Each step independently meaningful

API separation is natural in this case.

But if it’s executed with the same button on the same screen, it’s one business operation.


Decision Criteria

When deciding whether to split or combine APIs, this one question clarifies it:

When this operation fails, do you want to keep the partially successful results for the user?

  • Yes → Separate APIs / Frontend combination possible
  • No → Server should handle with 1-step API

Improvements in Numbers

Metric Before (Frontend Combination) After (1-step API)
API calls 5 1
Frontend code 110 lines 47 lines
Error cases 5 1
Transaction guarantee
Partial failure handling Frontend responsibility None (All or Nothing)

Conclusion

The problem wasn’t “2-step vs 1-step.” The problem was where to draw business boundaries.

  • One user action
  • One business meaning
  • One success/failure

If these conditions are met, it’s better to create a new API representing that operation rather than combining existing APIs.

If an API represents a single business operation, even if its internal implementation is divided into multiple domain operations, it’s preferable to expose it as one atomic endpoint externally.

Frontend should be closer to consuming results rather than orchestration.