Skip to content

Cross-Project Configuration

Cross-project messaging allows a FastPubSub service to consume from or publish to Pub/Sub resources outside its default broker project. This model is common in multi-team organizations where platform events are shared across isolated Google Cloud projects.

Conceptual Model

FastPubSub starts with a default broker project_id. You can override this default at subscriber, publisher, router, or per-call publish level.

flowchart LR
    A[Service in project-a] --> B[Default broker project-a]
    A --> C[Override to project-b]
    A --> D[Override to shared-platform]

This hierarchy supports localized decisions without duplicating broker instances across modules.

Subscriber-Level Override

# This subscriber uses the default project (project-a)
@broker.subscriber(
    alias="local-handler",
    topic_name="local-events",
    subscription_name="local-events-subscription",
)
async def handle_local_events(message: Message):
    await process_local_event(message.data)


# This subscriber uses a different project (project-b)
@broker.subscriber(
    alias="cross-project-handler",
    topic_name="shared-events",
    subscription_name="project-a-subscription",
    project_id="project-b",
    autocreate=True,
)
async def handle_cross_project_events(message: Message):
    await process_shared_event(message.data)

Use this when only specific subscriptions belong to a foreign project.

Publisher-Level Override

# Publisher for the default project
local_publisher = broker.publisher("local-events")

# Publisher for a different project
cross_project_publisher = broker.publisher(
    "shared-events", project_id="project-b"
)


@app.post("/send-event")
async def send_event(data: dict):
    # Publish to local project
    await local_publisher.publish(data)

    # Publish to other project
    await cross_project_publisher.publish(data)

You can also publish with broker-level API where project is specified per call:

@app.after_startup
async def publish_cross_project_broker() -> None:
    await broker.publish(
        topic_name="shared-events",
        data={"event": "cross_project"},
        project_id="other-project-id",
    )

Router-Scoped Project Mapping

Routers are useful when a whole domain maps to the same remote project.

# Router for external project
external_router = PubSubRouter(prefix="external", project_id="project-b")


@external_router.subscriber(
    alias="shared-handler",
    topic_name="shared-events",
    subscription_name="project-a-subscription",
)
async def handle_shared(message: Message):
    await process_shared_event(message.data)


@external_router.subscriber(
    alias="analytics-handler",
    topic_name="analytics-events",
    subscription_name="project-a-analytics-subscription",
)
async def handle_analytics(message: Message):
    await process_analytics(message.data)


# Include the router in the broker
broker.include_router(external_router)

Nested Project Scopes

Nested routers can progressively refine scope:

# Create a separate broker for nested router demo
nested_broker = PubSubBroker(project_id="project-a")

# First level router - uses project-b
level1_router = PubSubRouter(prefix="external", project_id="project-b")

# Second level router - uses project-c
level2_router = PubSubRouter(prefix="analytics", project_id="project-c")


# Subscriber uses project-c (inherited from level2)
@level2_router.subscriber(
    alias="handler",
    topic_name="metrics",
    subscription_name="metrics-subscription",
)
async def handle_metrics(message: Message):
    pass


level1_router.include_router(level2_router)
nested_broker.include_router(level1_router)

This pattern is useful when organizational boundaries differ by event domain.

Composite Service Example

# Complete example: service consuming from multiple projects
complete_broker = PubSubBroker(project_id="my-service")
complete_app = FastPubSub(complete_broker)


# Local events
@complete_broker.subscriber(
    alias="local-orders",
    topic_name="orders",
    subscription_name="orders-subscription",
)
async def handle_local_orders(message: Message):
    await process_order(message.data)


# Events from shared platform
platform_router = PubSubRouter(prefix="platform", project_id="shared-platform")


@platform_router.subscriber(
    alias="user-events",
    topic_name="user-events",
    subscription_name="my-service-user-subscription",
)
async def handle_user_events(message: Message):
    await sync_user_data(message.data)


@platform_router.subscriber(
    alias="notifications",
    topic_name="notifications",
    subscription_name="my-service-notifications-subscription",
)
async def handle_notifications(message: Message):
    await send_notification(message.data)


complete_broker.include_router(platform_router)


# Publishing to both projects
@complete_app.post("/create-order")
async def create_order(order: dict):
    # Local publish
    await complete_broker.publish("orders", order)

    # Notify platform
    await complete_broker.publish(
        "order-events",
        {"order_id": order["id"], "action": "created"},
        project_id="shared-platform",
    )

The service consumes local and shared-platform events while publishing to both contexts.

IAM Requirements

Cross-project configuration is correct only when IAM allows it. The service account executing your consumer must have permissions in the target project.

Subscribe Permissions

gcloud projects add-iam-policy-binding project-b \
  --member="serviceAccount:my-service@project-a.iam.gserviceaccount.com" \
  --role="roles/pubsub.subscriber"

Publish Permissions

gcloud projects add-iam-policy-binding project-b \
  --member="serviceAccount:my-service@project-a.iam.gserviceaccount.com" \
  --role="roles/pubsub.publisher"

Use Least Privilege

Prefer topic/subscription scoped bindings where feasible. Project-wide roles are simpler initially but increase blast radius.

Validation with PubSubTestClient

For local checks, validate project routing decisions in published metadata.

async def test_cross_project_publish_targets_expected_project() -> None:
    test_broker = PubSubBroker(project_id="project-a")

    async with PubSubTestClient(test_broker) as client:
        await client.publish(
            topic="shared-events",
            data={"event": "cross-project"},
            project_id="project-b",
        )
        published = client.get_published_messages()

    assert len(published) == 1
    assert published[0].project_id == "project-b"

This verifies your FastPubSub configuration intent without external infrastructure.

Design Recommendations

Naming Convention

Include producer and consumer context in subscription names, for example:

  • project-a-orders-subscription
  • my-service-user-events-subscription

Descriptive names reduce ambiguity in audits and incident response.

Dependency Registry

Maintain a simple mapping document:

  • Source project
  • Target project
  • Topic/subscription
  • Owning team
  • IAM principal

This materially improves change impact analysis.

Separate Platform and Product Flows

Where possible, group cross-project routes by router prefix (platform.*, external.*) to improve code discoverability.

Common Failure Modes

  • Correct code with missing IAM grants in target project.
  • Inconsistent naming for cross-project subscriptions.
  • Mixing local and cross-project publish paths without explicit project override.
  • Forgetting to document ownership boundaries across teams.

Recap

  • FastPubSub supports cross-project routing at subscriber, publisher, router, and per-publish levels.
  • IAM is a mandatory part of correctness for cross-project flows.
  • Router-level scoping keeps multi-project architectures maintainable.
  • PubSubTestClient helps validate routing intent in local tests.
  • Strong naming and ownership conventions are essential in shared environments.