Using Failure class
Decision
The Failure type serves as the standard return type for all use cases, and is optionally used in other methods and functions, such as those found in repositories.
The Failure type is defined as follows:
export class Failure {
constructor(
private readonly _errorCode: string,
private readonly _context?: Record<string, string>
) {
if (!_errorCode) {
throw new Error(
"Unexpected behavior: There is a Failure without errorCode"
);
}
}
get errorCode() {
return this._errorCode;
}
get httpStatus() {
return 400;
}
get context() {
return this._context;
}
}
In this setup, errorCode can be either a string defined by the current library/app or a value from the service returning the error. The Failure class lets you add extra metadata through an optional context argument. All class properties are read-only using getters, so they can't be changed after being set. The httpStatus property tells HTTP middlewares to respond with a 400 status code when a default Failure is returned. For other types of failures, like authentication or server errors, the class can be extended to return a different httpStatus code.
The Failure type, as outlined, ensures that error handling is predictable, transparent, and embedded within the type system, reducing the unpredictability and hidden behaviors associated with exception throwing. It promotes the kind of explicit, well-structured error management seen in functional programming, where every function’s potential outcomes are encoded and handled, improving overall code quality, safety, and maintainability. Using exceptions only for truly exceptional circumstances helps maintain a clear distinction between expected errors and system-level failures.
This approach also ensures consistent error handling across the codebase, adhering to TypeScript's type system, and allows for seamless integration with various components such as HTTP handlers or event-driven systems.
Problems
Throwing exceptions in application code can lead to several issues, primarily because it introduces unpredictability and can complicate error handling in large systems:
Unpredictability: Exceptions can be thrown from anywhere in the code, making it harder to track or anticipate when and where an error might occur. This unpredictability can lead to unexpected crashes, especially when exceptions are not properly handled at all call levels.
example:
try {
uploadToBufferCreatorInstance = this.uploadToBufferCreator({
headers: { "content-type": this.config["content-type"] },
});
} catch (err) {
return new MalformedContentError("Malformed content"); // Improperly handled exception. We assume all failures are due to "Malformed content," but the code can fail for other reasons, like network errors or issues with the uploadToBufferCreator implementation.
}
Hidden Control Flow: Exception throwing changes the control flow of the program abruptly. This makes the code harder to reason about because functions can fail in ways that aren’t obvious from their type signatures or their intended flow. Developers often have to inspect the entire codebase to understand where exceptions might be thrown.
try {
mapDocIds(doc, mapping, data);
} catch (err) {
if (err.message === "skipped") {
this.logger.info(
`[ProductEngine] Skipping doc with extId ${doc.extId} for model "${modelName}"`
);
docModels.splice(index, 1);
index -= 1;
continue;
}
if (err.isProductTypeError) {
this.logger.info(
`[ProductEngine] Skipping doc with extId ${doc.extId} for model "${modelName}" -> missing ProductType`
);
docModels.splice(index, 1);
index -= 1;
continue;
}
throw err;
}
Error Propagation: Exceptions propagate up the call stack until they are caught, but intermediate layers of the code might not handle them effectively. This leads to potential problems with error propagation, where errors can bypass logical handling points, causing unpredictable states.
// ...
async sendInvoice(invoiceId, { emails, languages }) {
try {
// ... long implementation
return this.createDeliveryJob(mailer);
} catch (err) {
this._logger.critical(`[MAILER] AdminMailer#sendInvoice. ${err}`); // Catch-all clauses assume that all errors should be handled the same way.
throw err;
}
}
// ...
async function sendEmail(req, res, next) {
try {
// ...
await mailer("AdminMailer").sendInvoice(invoiceId, { emails, languages }); // Error si thrown here but poorly handled
res.send(204, null);
} catch (error) {
return next(error);
}
}
Testing and Maintainability: Code that relies on exceptions can be harder to test, as developers need to account for both normal and exceptional cases in every test scenario. This also increases maintenance complexity, as any change that introduces new exception-throwing logic may have unintended consequences throughout the codebase.
describe("SupplierSelectionAutomation.PutSupplierSelectionAutomation", () => {
it("returns 400 when the use case throws an error", async () => {
useCaseExecuteMock.mockRejectedValueOnce("TEST ERROR");
/// ...
});
it("returns 400 when the automation id is not provided", async () => {
const event = {
pathParameters: {},
body: {
supplier: Supplier.SOME_SUPPLIER,
},
};
/// ...
});
it("returns 400 when the automation id is invalid", async () => {
const event = {
pathParameters: {
automationId: "invalidId",
},
body: {
supplier: Supplier.MELTER,
},
};
/// ...
});
it("executes the put supplier selection automation use case", async () => {
/// ...
});
});
Using exceptions (bad for composability):
function fetchCampaign(id: string): Campaign {
if (!id) throw new Error("Invalid campaign ID");
return new Campaign(id);
}
function fetchPrice(campaignId: string): Campaign[] {
if (!campaignId) throw new Error("Invalid campaign ID for price");
return [{ total: 1, campaignId }];
}
try {
const campaign = fetchCampaign("123");
const price = fetchPrice(campaign.id);
console.log(price);
} catch (error) {
console.error("Error:", error.message);
}
Using the Failure type (better for composability):
function fetchCampaign(id: string): Campaign | Failure {
if (!id) return new Failure(ErrorCodes.CampaignFetchErrorWithInvalidId);
return { data: new Campaign(id) };
}
function fetchPrice(campaignId: string): Campaign[] | Failure {
if (!campaignId) return new Failure(ErrorCodes.PriceFetchErrorWithInvalidId);
return { data: [{ total: 1, campaignId }] };
}
const campaignResult = fetchCampaign("123");
if (campaignResult instanceof Failure) return campaignResult;
const priceResult = fetchPrice(campaignResult.data.id);
if (priceResult instanceof Failure) return priceResult;
Context
We currently throw exceptions almost everywhere, but have started experimenting with returning Failure instances in the new pricing CDK app.
Options
The following other options were considered:
Returning null or undefined is a simple approach, but it requires the caller to always check for these values.
Pros: Simple to implement. Common in JavaScript.
Cons: Ambiguity: null or undefined could mean many things, such as an error or simply no data. Easy to forget to check for null, leading to runtime errors.
Use throw (Exceptions)
As mentioned earlier, using exceptions can be problematic for control flow, but they are still widely used, especially for unexpected or exceptional errors.
Pros: Explicit error signaling. Can handle deeply nested errors with one try/catch block.
Cons: Disrupts control flow and requires careful use of try/catch. Can lead to inconsistent error handling and stack trace loss, especially with async code.
Using the Result type
The Result type is an easy way to represent either a successful outcome with data or an error with an errorCode, ensuring only one outcome at a time.
export type Result<DataType> = SuccessResult<DataType> | ErrorResult;
interface SuccessResult<DataType> {
errorCode?: never;
data: DataType;
}
interface ErrorResult {
errorCode: ErrorCode;
data?: never;
}
Pros: Makes it easy to work with return values without relying on exceptions, leading to more predictable and manageable code. Forces us to explicitly handle both success and error cases, reducing the likelihood of unhandled errors.
Cons: The SuccessResult type adds complexity, as the return type could be checked using instanceof instead. It can be difficult to add additional information to ErrorResult when needed.
Reasoning
The Failure type improves our code by offering a simple, safe, and consistent way to manage errors. It helps us write easy-to-maintain code by adding just one type with a clear interface. This eliminates the need for try/catch blocks and keeps the error-handling logic separate from Error classes. The throw new Error() mechanism should be reserved only for truly exceptional cases that result in a legitimate crash.
This technique can be combined with generic code handlers tailored to each specific usage type. Below is an example of its application in an AWS REST API handler:
const innerHandler = (event: { pathParameters: GetCampaignPathParams}): Promise<Campaign | Failure> => {
const usecase = new GetCampaign(await dependencies);
return usecase.execute(event.pathParameters.id);
};
/// ...
export const handler = corsHandler(["example.com"])(
exceptionHandler(
validatePathParameters(schema)(
httpResponseSerializer(innerHandler)
)
)
);
Consequences
How do we implement this change?
Whenever a team writes a new endpoint or updates code that can benefit from improved error handling, they should use Failure as the return type instead of throwing exceptions. Additionally, during normal refactoring, if time allows, teams should update existing code to use Failure returns. However, we won't refactor code solely to change the return type.
Who will implement the change?
While there isn’t much to cover, the payment team is happy to organize a Q&A session if needed. Please feel free to reach out if you have any questions or need further clarification!
How do we teach this change?
While there isn’t much to cover, the payment team is happy to organize a Q&A session if needed.
What could go wrong?
While the Failure type is intended as the standard, some teams may not adopt it consistently, leading to a mixed codebase and inconsistent error handling. Refactoring existing exception-heavy code can be time-consuming, risky, and may introduce new bugs, particularly in legacy systems where exceptions are deeply embedded.
What do we do if something goes wrong?
If migrating to the Failure type proves challenging, we can allow teams to continue using their preferred approach or reconsider this ADR.
What is still unclear?
There are no major concerns with adopting the Failure type as our standard for return types. If any issues arise, we can always reassess and adjust our approach to return types in use cases as needed. This flexibility allows us to refine the implementation over time without significant disruption.