Files
marco/.agents/skills/ember-best-practices/rules/service-owner-linkage.md

12 KiB
Raw Blame History

title, impact, impactDescription, tags
title impact impactDescription tags
Manage Service Owner and Linkage Patterns MEDIUM-HIGH Better service organization and dependency management services, owner, linkage, dependency-injection, architecture

Manage Service Owner and Linkage Patterns

Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.

Owner and Linkage Fundamentals

Incorrect (manual service instantiation):

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import ApiService from '../services/api';

class UserProfile extends Component {
  // ❌ Creates orphaned instance without owner
  api = new ApiService();

  async loadUser() {
    // Won't have access to other services or owner features
    return this.api.fetch('/user/me');
  }

  <template>
    <div>{{@user.name}}</div>
  </template>
}

Correct (proper service injection with owner):

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';

class UserProfile extends Component {
  // ✅ Proper injection with owner linkage
  @service api;

  async loadUser() {
    // Has full owner context and can inject other services
    return this.api.fetch('/user/me');
  }

  <template>
    <div>{{@user.name}}</div>
  </template>
}

Manual Owner Passing (Without Libraries)

Creating instances with owner:

// app/components/data-processor.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { service } from '@ember/service';

class DataTransformer {
  @service store;

  transform(data) {
    // Can use injected services because it has an owner
    return this.store.request({ url: '/transform', data });
  }
}

class DataProcessor extends Component {
  @service('store') storeService;

  constructor(owner, args) {
    super(owner, args);

    // Manual instantiation with owner linkage
    this.transformer = new DataTransformer();
    setOwner(this.transformer, getOwner(this));
  }

  processData(data) {
    // transformer can now access services
    return this.transformer.transform(data);
  }

  <template>
    <div>Processing...</div>
  </template>
}

Factory pattern with owner:

// app/utils/logger-factory.js
import { getOwner } from '@ember/application';

class Logger {
  constructor(owner, context) {
    this.owner = owner;
    this.context = context;
  }

  get config() {
    // Access configuration service via owner
    return getOwner(this).lookup('service:config');
  }

  log(message) {
    if (this.config.enableLogging) {
      console.log(`[${this.context}]`, message);
    }
  }
}

export function createLogger(owner, context) {
  return new Logger(owner, context);
}
// Usage in component
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { createLogger } from '../utils/logger-factory';

class My extends Component {
  logger = createLogger(getOwner(this), 'MyComponent');

  performAction() {
    this.logger.log('Action performed');
  }

  <template>
    <button {{on "click" this.performAction}}>Do Something</button>
  </template>
}

Owner Passing with Modern Libraries

Using reactiveweb's link() for ownership and destruction:

The link() function from reactiveweb provides both ownership transfer and automatic destruction linkage.

// app/components/advanced-form.gjs
import Component from '@glimmer/component';
import { link } from 'reactiveweb/link';

class ValidationService {
  validate(data) {
    // Validation logic
    return data.email && data.email.includes('@');
  }
}

class FormStateManager {
  data = { email: '' };

  updateEmail(value) {
    this.data.email = value;
  }
}

export class AdvancedForm extends Component {
  // link() handles both owner and destruction automatically
  validation = link(this, () => new ValidationService());
  formState = link(this, () => new FormStateManager());

  get isValid() {
    return this.validation.validate(this.formState.data);
  }

  <template>
    <form>
      <input value={{this.formState.data.email}} />
      {{#if (not this.isValid)}}
        <span>Invalid form</span>
      {{/if}}
    </form>
  </template>
}

Why use link():

Services Outside app/services Directory

Using createService from ember-primitives:

// app/components/analytics-tracker.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { createService } from 'ember-primitives/utils';

// Define service logic as a plain function
function AnalyticsService() {
  let events = [];

  return {
    get events() {
      return events;
    },

    track(event) {
      events.push({ ...event, timestamp: Date.now() });

      // Send to analytics endpoint
      fetch('/analytics', {
        method: 'POST',
        body: JSON.stringify(event),
      });
    },
  };
}

export class AnalyticsTracker extends Component {
  // createService handles owner linkage and cleanup automatically
  analytics = createService(this, AnalyticsService);

  <template>
    <div>Tracking {{this.analytics.events.length}} events</div>
  </template>
}

Why createService:

Co-located services with components:

// app/components/shopping-cart/service.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins';
import { action } from '@ember/object';

export class CartService extends Service {
  @tracked items = new TrackedArray([]);

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  @action
  addItem(item) {
    this.items.push(item);
  }

  @action
  removeItem(id) {
    const index = this.items.findIndex((item) => item.id === id);
    if (index > -1) this.items.splice(index, 1);
  }

  @action
  clear() {
    this.items.clear();
  }
}
// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { CartService } from './service';

class ShoppingCart extends Component {
  cart = (() => {
    const instance = new CartService();
    setOwner(instance, getOwner(this));
    return instance;
  })();

  <template>
    <div class="cart">
      <h3>Cart ({{this.cart.items.length}} items)</h3>
      <div>Total: ${{this.cart.total}}</div>

      {{#each this.cart.items as |item|}}
        <div class="cart-item">
          {{item.name}}
          - ${{item.price}}
          <button {{on "click" (fn this.cart.removeItem item.id)}}>
            Remove
          </button>
        </div>
      {{/each}}

      <button {{on "click" this.cart.clear}}>Clear Cart</button>
    </div>
  </template>
}

Service-like utilities in utils/ directory:

// app/utils/notification-manager.js
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
import { setOwner } from '@ember/application';

export class NotificationManager {
  @tracked notifications = new TrackedArray([]);

  constructor(owner) {
    setOwner(this, owner);
  }

  @action
  add(message, type = 'info') {
    const notification = {
      id: Math.random().toString(36),
      message,
      type,
      timestamp: Date.now(),
    };

    this.notifications.push(notification);

    // Auto-dismiss after 5 seconds
    setTimeout(() => this.dismiss(notification.id), 5000);
  }

  @action
  dismiss(id) {
    const index = this.notifications.findIndex((n) => n.id === id);
    if (index > -1) this.notifications.splice(index, 1);
  }
}
// app/components/notification-container.gjs
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { NotificationManager } from '../utils/notification-manager';

class NotificationContainer extends Component {
  notifications = new NotificationManager(getOwner(this));

  <template>
    <div class="notifications">
      {{#each this.notifications.notifications as |notif|}}
        <div class="notification notification-{{notif.type}}">
          {{notif.message}}
          <button {{on "click" (fn this.notifications.dismiss notif.id)}}>
            ×
          </button>
        </div>
      {{/each}}
    </div>

    {{! Example usage }}
    <button {{on "click" (fn this.notifications.add "Success!" "success")}}>
      Show Notification
    </button>
  </template>
}

Registering Custom Services Dynamically

Runtime service registration:

// app/instance-initializers/dynamic-services.js
export function initialize(appInstance) {
  // Register service dynamically without app/services file
  appInstance.register(
    'service:feature-flags',
    class FeatureFlagsService {
      flags = {
        newDashboard: true,
        betaFeatures: false,
      };

      isEnabled(flag) {
        return this.flags[flag] || false;
      }
    },
  );

  // Make it a singleton
  appInstance.inject('route', 'featureFlags', 'service:feature-flags');
  appInstance.inject('component', 'featureFlags', 'service:feature-flags');
}

export default {
  initialize,
};

Using registered services:

// app/components/feature-gated.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';

class FeatureGated extends Component {
  @service featureFlags;

  get shouldShow() {
    return this.featureFlags.isEnabled(this.args.feature);
  }

  <template>
    {{#if this.shouldShow}}
      {{yield}}
    {{else}}
      <div class="feature-disabled">This feature is not available</div>
    {{/if}}
  </template>
}

Best Practices

  1. Use @service decorator for app/services - cleanest and most maintainable
  2. Use link() from reactiveweb for ownership and destruction linkage
  3. Use createService from ember-primitives for component-scoped services without extending Service class
  4. Manual owner passing for utilities that need occasional service access
  5. Co-located services for component-specific state that doesn't need global access
  6. Runtime registration for dynamic services or testing scenarios
  7. Always use setOwner when manually instantiating classes that need services

When to Use Each Pattern

  • app/services: Global singletons needed across the app
  • link() from reactiveweb: When you need both owner and destruction linkage
  • createService from ember-primitives: Component-scoped services without Service class
  • Co-located services: Component-specific state, not needed elsewhere
  • Utils with owner: Stateless utilities that occasionally need config/services
  • Runtime registration: Dynamic configuration, feature flags, testing

Reference: Ember Owner API, Dependency Injection, reactiveweb link(), ember-primitives createService