Skip to content

docs: Design typed pipeline #74

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft

docs: Design typed pipeline #74

wants to merge 10 commits into from

Conversation

nielsenko
Copy link
Collaborator

@nielsenko nielsenko commented May 16, 2025

Description

This PR introduces a design for a new typed pipeline system in Relic. The goal is to improve both developer ergonomics (clear, type-safe access to context data like context.user) and compile-time safety, preventing errors from misconfigured middleware. The core idea involves ContextProperty, extension type views, and a generics based PipelineBuilder. See DESIGN_typed_context_pipeline.md.

Copy link

coderabbitai bot commented May 16, 2025

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@nielsenko nielsenko changed the title Design typed pipeline docs: Design typed pipeline May 16, 2025
@nielsenko nielsenko self-assigned this May 16, 2025
@nielsenko
Copy link
Collaborator Author

@coderabbitai could you review the design document in DESIGN_typed_context_pipeline.md

Copy link

coderabbitai bot commented May 16, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's check if the file exists in the repository
find . -name "DESIGN_typed_context_pipeline.md" -type f
# If found, display its content
cat DESIGN_typed_context_pipeline.md

Length of output: 18902


@nielsenko I've reviewed the DESIGN_typed_context_pipeline.md document and I'm impressed with the thoughtful approach to implementing a typed context pipeline in Relic.

Overall Assessment

This is an excellent design document that presents a clever solution to the common problem of ensuring middleware is correctly composed. The use of Dart's type system, particularly extension types, to catch errors at compile-time rather than runtime represents a significant improvement over traditional approaches.

Strengths

  • Compile-time safety: The design effectively leverages Dart's type system to verify middleware composition early.
  • Zero-cost abstractions: Extension types provide type safety without runtime overhead.
  • Elegant solution to "class explosion": Using extension types + ContextProperty avoids needing separate classes for each middleware combination.
  • Developer-friendly API: Intuitive access to context data (e.g., context.user).
  • Well-documented paradigm shift: Clear explanation of changes from traditional middleware patterns.

Suggestions for Enhancement

  1. Error handling: Consider elaborating on how exceptions thrown by middleware are caught and transformed into HTTP responses. A recommended exception hierarchy would be helpful.

  2. Testing strategy: Add guidance on testing middleware in isolation with this new paradigm.

  3. Performance considerations: While extension types are zero-cost, a brief analysis of Expando lookup overhead would be valuable.

  4. Dependency injection: Address how services would be injected into middleware functions.

  5. Migration path: If replacing an existing system, provide migration guidelines for existing users.

  6. Complex scenarios: Consider adding advanced examples for nested pipelines or complex routing scenarios.

  7. Lifecycle management: Add details about context data lifecycle and memory management with Expandos.

Technical Implementation

The core approach using extension types, Expando, and generic composition is solid. The ContextProperty helper and PipelineBuilder implementation effectively demonstrate the value of compile-time type checking.

This design represents a sophisticated use of Dart's type system that should significantly improve developer experience by catching errors early and providing clear, type-safe access to context data.

@nielsenko nielsenko requested review from SandPod and tp May 16, 2025 16:44
@nielsenko nielsenko force-pushed the path-trie-wildcard-and-tail branch from da468b6 to e253ee5 Compare May 16, 2025 17:39
- Move DESIGN_typed_context_pipeline into design folder
- Add runnable simplified example code in design/appendix_a.dart
@nielsenko nielsenko force-pushed the design-typed-pipeline branch from 771b3a8 to 935e0dd Compare May 18, 2025 09:48
@nielsenko
Copy link
Collaborator Author

@coderabbitai Added extra section on routing as well as a larger runnable sample in appendix_a.dart. Could update your review?

Copy link

coderabbitai bot commented May 18, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@nielsenko Thank you for the updates! I've reviewed the new section on routing and the appendix_a.dart sample.

Updated Assessment

The additions significantly strengthen the design document and address several of my previous suggestions:

New Routing Section

The routing section elegantly demonstrates how the typed context pipeline integrates with HTTP routing mechanisms. I appreciate how it shows:

  • How route parameters can be accessed via typed context properties
  • The pattern for combining routing with middleware composition
  • Clear separation between routing logic and middleware application

This addresses my previous suggestion about complex scenarios by showing how the pipeline works in real routing situations.

Appendix A Sample Code

The appendix_a.dart sample is an excellent addition that:

  • Provides a complete, runnable implementation that validates the design
  • Demonstrates practical usage of the typed context in a full application flow
  • Shows real-world middleware composition including authentication and validation
  • Illustrates how errors can be handled within the middleware chain
  • Makes the abstract concepts concrete through working code

This sample effectively serves as both documentation and proof-of-concept, addressing my suggestion about complex scenarios and implementation details.

Remaining Enhancement Opportunities

While the new additions address several of my previous suggestions, a few areas could still be enhanced:

  1. Testing strategy: Consider adding guidance on unit testing middleware that relies on the typed context pattern.

  2. Performance considerations: A brief discussion of Expando lookup performance characteristics would be valuable.

  3. Dependency injection: If services need to be injected into middleware functions, explaining the recommended approach would be helpful.

Overall, these additions make the design document substantially more complete and practical. The combination of theoretical explanation, practical examples, and runnable code creates a comprehensive guide that should be very valuable to developers implementing this pattern.


🏁 Script executed:

#!/bin/bash
# First, let's check if the files exist in the repository
find . -name "DESIGN_typed_context_pipeline.md" -type f
find . -name "appendix_a.dart" -type f

# Let's read the updated design document
echo "=== DESIGN_typed_context_pipeline.md ==="
cat DESIGN_typed_context_pipeline.md

# Let's read the new appendix file
echo "=== appendix_a.dart ==="
cat appendix_a.dart

Length of output: 483


🏁 Script executed:

#!/bin/bash
# Read the files from their correct paths
echo "=== DESIGN_typed_context_pipeline.md ==="
cat ./design/DESIGN_typed_context_pipeline.md

echo -e "\n\n=== appendix_a.dart ==="
cat ./design/appendix_a.dart

Length of output: 35513


🏁 Script executed:

#!/bin/bash
# Let's examine the design document and the appendix file from their correct paths
cat ./design/DESIGN_typed_context_pipeline.md > design_doc.txt
cat ./design/appendix_a.dart > appendix.txt

# Let's look at the Method enum definition which is used in the appendix
grep -r "class Method" .

# Inspect the Router class that's used in the examples
find . -name "router.dart" -type f
cat ./lib/src/router/router.dart 2>/dev/null || echo "Router file not found"

# Check for Expando class, as it's a key part of the design
find . -name "context.dart" -type f
cat ./lib/src/adapter/context.dart 2>/dev/null || echo "Context file not found"

Length of output: 9679


@nielsenko Thank you for the updates! I've reviewed the new section on routing and the appendix_a.dart example.

Updated Assessment

The additions substantially enhance the design document and address several of my previous suggestions.

New Routing Section

The routing integration section is a valuable addition that demonstrates:

  • Clean integration between the typed context pipeline and Relic's Router<T> class
  • Elegant handling of route parameters via the RouteParameters class and corresponding context views
  • A well-thought-out routing dispatcher that bridges middleware chains and endpoint handlers
  • A flexible architecture supporting multiple specialized pipelines for different application sections

This effectively addresses my previous suggestion about handling complex scenarios by showing how routing fits into the overall design.

Appendix A Example

The runnable example in appendix_a.dart is particularly impressive and brings the design to life:

  • Demonstrates practical implementation of context views, middleware, and handlers
  • Shows how to construct specialized pipelines for different parts of an application
  • Illustrates error handling via exceptions
  • Includes request simulation covering various scenarios:
    • Successful API and admin authentication
    • Failed authentication
    • Public routes
    • Non-existent routes

This implementation example makes the abstract concepts concrete and provides a complete reference for developers implementing the pattern.

Remaining Enhancement Opportunities

While these additions significantly strengthen the design, a few minor areas could still be enhanced:

  1. Testing strategy: Adding guidance on unit testing middleware and handlers would round out the implementation guidance.

  2. Performance considerations: A brief note on the runtime performance implications of the Expando-based approach would be helpful.

  3. Dependency injection: If your framework uses dependency injection, consider mentioning how services would be injected into middleware and handlers.

Overall, these additions have elevated an already strong design document into a comprehensive guide. The combination of theoretical explanation, practical examples, and runnable code creates an excellent reference for developers implementing this pattern.

Copy link
Collaborator

@tp tp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applaud what you're trying to achieve here 😀

It's really unfortunate that many of the "obvious" things we would want to do with generics here (like having an add that is ExistingMiddleware & T addMiddleware(Middleware<T> middleware)) is currently not possible.

I was not a big fan of the mixins in each route, but I wonder if the higher-level like a Router / Pipeline is exactly where that would make sense and solve a ton of type-issues and where stuff would only have to be added/changed in a single place.
(Having applications subclass Pipline with A, B, C is probably the easiest one, another might be to have an explicit middleware step (method) that outputs a new context (by calling into the middleware). Offers more flexibility, but less out of the box "speed".)

Just my 2 cents, will have to ponder this for a bit.
Also many of the ideas that came to my mind for how to handle this, just don't work out nicely in practice…

Looking forward to having a chat about this :)


final _adminRoleProperty = ContextProperty<AdminRole>('admin_role');
extension type AdminContextView(RequestContext _relicContext)
implements UserContextView {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised this would even compile, because the type does not conform to UserContextView.

And indeed when I remove the userView from adminAuthMiddleware, it still compiles, but then fails at runtime:

Unhandled error: Bad state: Property user not found

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow.

xtension type AdminContextView(RequestContext _relicContext) { // missing implements

will introduce a compile error in:

FutureOr<Response> handleAdminDashboard(final AdminContextView context) {
  print(
      'Handling Admin Dashboard for ${context.user.name} (${context.adminRole.roleName})');
  return Response.ok(
      'Admin: ${context.user.name}, Role: ${context.adminRole.roleName}');
}

The meaning of implements for extension types are defined here.

In particular:

An extension type can only implement:
...

  • Another extension type that is valid on the same representation type. This allows you to reuse operations across multiple extension types (similar to multiple inheritance).

Copy link
Collaborator Author

@nielsenko nielsenko May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I defined:

extension type AdminContextView._(RequestContext _relicContext)
    implements UserContextView {
  AdminContextView(UserContextView uv) : _relicContext = uv._relicContext;

  // Admin also has User
  AdminRole get adminRole => _adminRoleProperty.get(_relicContext);
  void attachAdminRole(final AdminRole role) =>
      _adminRoleProperty.set(_relicContext, role);
}

would that be better?

In general the example don't do full encapsulation. You could argue the ctor should take AdminRole as well and do the attach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow.

I did not mean removing the implements keyword, but rather the implementation (of assigning the user ID inside the admin context view).

I can remove the implementation, get it to compile and then error at runtime.
(We discussed this already, just for completeness sake here.)

AdminContextView(UserContextView uv) in the constructor seems like a better pattern to me, pushing the implementation to be more correct. Sadly that only solves 1 layer of inheritance. If you wanted to build a combined view that had implements A, B that would not work as well (unless you take 2 constructor params and hope that they point fo the same context…).

}

// Admin Auth: Adds User and AdminRole, returns AdminContextView
AdminContextView adminAuthMiddleware(final BaseContextView inputView) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with the AdminContext implementing the UserContext, this example is a bit too simple.

Because there it "makes sense" that one could implement the other.

But assume we had a "client language" context (which reads the HTTP header) instead. Then it would feel weird to that in the pipeline of

  .add(logger)
  .add(clientLanguage)
  .add(user)

We would have to wrap/modify the user middleware to also include the language, right?

Unless I am mistaken on this point, I think it's a bit of a code-smell if in order to add a new middleware I would have to change 2 places in tandem.

Maybe then it would be better if (for example, just a spitball) a router/server was an abstract class to instantiate for ones specific project, which would use mixins with all the middleware, and then the route could just depend on it (and routes provided from third party packages would require you to add that mixin to provide the right context before being able to "mount" them – or they use a subrouter which has those only for the affected routes).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 let's discuss

}

// === ContextProperty and Views Stubs ===
class ContextProperty<T extends Object> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can get behind this pattern.

I think such a declaration is very smart, because it gives you a unique identifier and here (unlike in riverpod etc.) it's only the helper / identifier instance and does not practically have any global state (as for the you need the specific request context / token).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this pattern makes sense with the current paradigm as well.

* It provides access to the current `Request` object (e.g., via a `request` getter).
* It contains a stable `token` (e.g., via a `token` getter). This `token` is a unique identifier for the entire lifecycle of a single request and remains constant even if the `RequestContext` instance itself undergoes state transitions (e.g., from `NewContext` to `ResponseContext`).

All request-scoped data attached via `Expando` (managed by `ContextProperty`) will be keyed off this stable `requestToken`. The extension type views defined in this design will wrap an instance of Relic's `RequestContext`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about the performance characteristics with using the various expandos. I think we could similarly have just the state on the context / token itself, and then provide getter / setter like V get(ContextProperty<V> prop) on it.

I like how easy it is in the current approach to hide the setter though (just making that part file-local for example), and wonder if replicating this would be much harder in my above suggestion (e.g. you could have something like Completer, with an "value reader" and "value setter" being distinct, and only 1 exported as a direct helper).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todays Expandos are as about as fast as HashMaps. As a context bag would be implemented with a map as well, it is not clear to me what would be faster.

BTW, this is abstracted by the ContextProperty<T> so we can "easily" change it, if any speed gain mandate.

final _sessionProperty = ContextProperty<Session>('relic.session');

// A composite view indicating both User and Session information are available.
extension type UserSessionContextView(RequestContext _relicContext) implements UserContextView, SessionContextView {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the Achilles' heel of the proposal.

While the application could easily build "the one full context" (per router), how would this combine with externally provided middle?

Wouldn't I have to wrap each step in a *ContextView that threads through all the previous one (specific to my current app configuration)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you have to define these combinators, even if they are trivial.

if only we could do:

(T with FancyContext) middleware<T extends BaseContextView>(T context) => ...; // 

ie. return a unnamed type that is the composition of another type with a mixin.

* Handlers and middleware can declare precisely the context view (and thus data) they expect.
* Access to context data via extension type getters is type-safe and clear (e.g., `context.user`).
* **Minimal Runtime Overhead for Views**: Extension types are intended to be zero-cost compile-time wrappers. The `ContextProperty` helper encapsulates `Expando` lookups/attachments, which are generally efficient.
* **No Class Explosion**: Avoids needing a distinct context `class` for every possible combination of middleware. Extension types provide views, and `ContextProperty` manages data association with the stable `requestToken`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoids needing a distinct context class for every possible combination of middleware.

Is this true though? Maybe I misunderstand some aspect of the implementation or how I could avoid that, but I feel like I would always need to make the "next" middleware aware of the previous (and thus alter it's type when pre-pending a new one). (Assuming all middleware enhances the context with local data and is not just a passthrough like the logger in this example.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no class explosion in the sense that context views are zero cost extension types. They have no runtime representation.


This typed pipeline introduces a shift from the traditional `Middleware = Handler Function(Handler innerHandler)` pattern previously used. Understanding these changes is crucial:

* **New Middleware Signature**: Middleware in this design are functions with a signature like `OutputView Function(InputView)`. They transform context views rather than wrapping an inner handler.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting here would be awesome 🤩

* **Linear Chain**: The `PipelineBuilder` composes middleware into a linear chain of context transformations. Each middleware is expected to process the context and pass control (via its return value) to the next stage defined by the builder.
* **Short-Circuiting (e.g., Denying Access)**:
* Middleware should not directly return a `Response` to short-circuit the pipeline.
* Instead, if a middleware needs to stop processing and return an error (e.g., an authentication middleware denying access due to an invalid token), it should **throw a specific exception** (e.g., `AuthorizationRequiredError("Invalid token")`, `PaymentRequiredError()`).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky, personally I was not a fan of the throw.

But maybe this could be handle with some Result-ish type which distinct between "early return" and "next".

But either way, I think that is a secondary consideration and would not need to happen in combination with the major change of this proposal, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, having just fought to get rid of HijackException based control flow I know where you are coming from.

However, you won't inadvertently catch these exceptions in the pipeline as the middleware don't wrap each other as today.

I kind of made peace with it. Passing a context to a handler that it shouldn't react to is also a bit weird, and then there is the question of typing.

* **Boilerplate for `ContextProperty` and Views**: Each new piece of context data requires defining a `ContextProperty` instance and corresponding view methods. However, this is more structured and less error-prone than raw `Expando` usage.
* **Learning Curve**: Developers using the framework will need to understand context views, `ContextProperty`, the role of `requestToken`, the pipeline builder, and the implications of the new middleware paradigm.
* **Discipline with `requestToken`**: The `ContextProperty` helper ensures that data is keyed off the stable `token` within the `RequestContext`, mitigating direct misuse of `Expando`s with transient `RequestContext` instances themselves as keys.
* **Middleware Return Types**: Middleware authors must be careful to return the correct context view type that accurately reflects the data they've attached via `ContextProperty` and the `requestToken`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this already refers to the issue I have encountered below, where I could get the code to compile but the middleware implementation to be wrong and err at runtime.

I suppose "just" needing to take care in the middleware implementation (which should mostly be small) is still a win over having to manually match types across the entire router setup though.

So maybe this is the right trade-off. For comparison though, maybe we should take a look at what costs a fully type-safe middleware implementation would really entail. E.g. I imagine that even a simple middleware like an auth check or custom logger would access a network store (to validate the session), or do a big computation (to verify a token), such that creating a single class instance to store/wrap the result would not be a practical slow-down at all.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking forward to discuss your thoughts on this.

Base automatically changed from path-trie-wildcard-and-tail to main May 19, 2025 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants